Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c5024c8c2 | ||
|
|
01e7b17fcd | ||
|
|
c4c15b8fd2 | ||
|
|
7f95753cf2 | ||
|
|
0ebb88ec08 | ||
|
|
94b63c6ba7 | ||
|
|
3d70e0da86 | ||
|
|
b39d5cd6ab |
@@ -169,6 +169,20 @@ jobs:
|
|||||||
- name: Windows/Linux rehearsal (no publish)
|
- name: Windows/Linux rehearsal (no publish)
|
||||||
if: github.event_name == 'workflow_dispatch' && runner.os != 'macOS'
|
if: github.event_name == 'workflow_dispatch' && runner.os != 'macOS'
|
||||||
run: npx electron-builder ${{ matrix.builder_args }} --publish never
|
run: npx electron-builder ${{ matrix.builder_args }} --publish never
|
||||||
|
- name: Exercise packaged demo runtime (own Electron, no system Chrome)
|
||||||
|
run: |
|
||||||
|
case "$RUNNER_OS" in
|
||||||
|
macOS)
|
||||||
|
app="dist/mac-arm64/LearnHarness.app/Contents"
|
||||||
|
node scripts/smoke-demo-runtime.mjs --standalone "$app/Resources/server/standalone" --runtime "$app/MacOS/LearnHarness" --browser
|
||||||
|
;;
|
||||||
|
Windows)
|
||||||
|
node scripts/smoke-demo-runtime.mjs --standalone dist/win-unpacked/resources/server/standalone --runtime dist/win-unpacked/LearnHarness.exe --browser
|
||||||
|
;;
|
||||||
|
Linux)
|
||||||
|
xvfb-run -a node scripts/smoke-demo-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone --runtime dist/linux-unpacked/learnharness --browser
|
||||||
|
;;
|
||||||
|
esac
|
||||||
- name: Save installers and update metadata
|
- name: Save installers and update metadata
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { MessageResponse } from "@/components/ai-elements/message";
|
import { MessageResponse } from "@/components/ai-elements/message";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ArtifactPartProps } from "@/src/core";
|
import { repairMathForDisplay, type ArtifactPartProps } from "@/src/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The practice transcript cards (practice sessions, owner design
|
* The practice transcript cards (practice sessions, owner design
|
||||||
@@ -66,7 +66,7 @@ export function ProblemCard({ part }: ArtifactPartProps) {
|
|||||||
<div data-testid="problem-card" className="lh-card-enter my-1 rounded-xl border bg-card p-4">
|
<div data-testid="problem-card" className="lh-card-enter my-1 rounded-xl border bg-card p-4">
|
||||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
||||||
<div className="lh-prose text-[15px]">
|
<div className="lh-prose text-[15px]">
|
||||||
<MessageResponse>{statement}</MessageResponse>
|
<MessageResponse>{repairMathForDisplay(statement)}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -133,7 +133,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
|
|||||||
(owner round 3): the same quiet 50% register as quiz explanations. */}
|
(owner round 3): the same quiet 50% register as quiz explanations. */}
|
||||||
{verdict.length > 0 && (
|
{verdict.length > 0 && (
|
||||||
<div className="text-sm text-foreground/50">
|
<div className="text-sm text-foreground/50">
|
||||||
<MessageResponse>{verdict}</MessageResponse>
|
<MessageResponse>{repairMathForDisplay(verdict)}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Then the divider, then the labeled model solution (Slice G: the
|
{/* Then the divider, then the labeled model solution (Slice G: the
|
||||||
@@ -144,7 +144,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
|
|||||||
<div>
|
<div>
|
||||||
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Model solution</div>
|
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Model solution</div>
|
||||||
<div className="lh-prose text-[15px]">
|
<div className="lh-prose text-[15px]">
|
||||||
<MessageResponse>{reveal}</MessageResponse>
|
<MessageResponse>{repairMathForDisplay(reveal)}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { joinDictation, useDictationTarget } from "@/components/dictation";
|
import { joinDictation, useDictationTarget } from "@/components/dictation";
|
||||||
import { useEnterSubmits } from "@/components/enter-submits";
|
import { useEnterSubmits } from "@/components/enter-submits";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { repairMathForDisplay } from "@/src/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The practice dock (practice sessions, owner design 2026-10-04): an
|
* The practice dock (practice sessions, owner design 2026-10-04): an
|
||||||
@@ -108,7 +109,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
|||||||
<div className="max-h-[var(--dock-max-h,40vh)] overflow-y-auto px-4 pt-3 pb-2">
|
<div className="max-h-[var(--dock-max-h,40vh)] overflow-y-auto px-4 pt-3 pb-2">
|
||||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
||||||
<div className="lh-prose text-[15px]">
|
<div className="lh-prose text-[15px]">
|
||||||
<MessageResponse>{input.statement}</MessageResponse>
|
<MessageResponse>{typeof input.statement === "string" ? repairMathForDisplay(input.statement) : input.statement}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// No Electron import: the shipping helper's readiness contract is testable
|
||||||
|
// without a display. Never announce CDP until its exact loopback endpoint works.
|
||||||
|
const http = require("node:http");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} port
|
||||||
|
* @param {{signal?: AbortSignal, timeoutMs?: number, requestTimeoutMs?: number, retryMs?: number}} [options]
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
function waitForBrowserService(port, {
|
||||||
|
signal,
|
||||||
|
timeoutMs = 5000,
|
||||||
|
requestTimeoutMs = 500,
|
||||||
|
retryMs = 50,
|
||||||
|
} = {}) {
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
return Promise.reject(new Error("browser-service: invalid loopback port"));
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
let activeRequest;
|
||||||
|
let attemptTimer;
|
||||||
|
let retryTimer;
|
||||||
|
let lastError;
|
||||||
|
const finish = (error, endpoint) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(deadline);
|
||||||
|
clearTimeout(attemptTimer);
|
||||||
|
clearTimeout(retryTimer);
|
||||||
|
signal?.removeEventListener("abort", abort);
|
||||||
|
activeRequest?.destroy();
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(endpoint);
|
||||||
|
};
|
||||||
|
const abort = () => finish(signal.reason instanceof Error ? signal.reason : new Error("browser-service readiness aborted"));
|
||||||
|
const deadline = setTimeout(() => finish(new Error(
|
||||||
|
`browser-service: http://127.0.0.1:${port}/json/version not ready within ${timeoutMs}ms`,
|
||||||
|
{ cause: lastError },
|
||||||
|
)), timeoutMs);
|
||||||
|
const attempt = () => {
|
||||||
|
if (settled) return;
|
||||||
|
let done = false;
|
||||||
|
let response;
|
||||||
|
const complete = (error, endpoint) => {
|
||||||
|
if (done || settled) return;
|
||||||
|
done = true;
|
||||||
|
clearTimeout(attemptTimer);
|
||||||
|
response?.destroy();
|
||||||
|
request.destroy();
|
||||||
|
activeRequest = undefined;
|
||||||
|
if (!error) return finish(null, endpoint);
|
||||||
|
lastError = error;
|
||||||
|
retryTimer = setTimeout(attempt, retryMs);
|
||||||
|
};
|
||||||
|
const request = http.get({
|
||||||
|
hostname: "127.0.0.1", port, path: "/json/version", agent: false,
|
||||||
|
}, (res) => {
|
||||||
|
response = res;
|
||||||
|
res.on("error", (error) => complete(error));
|
||||||
|
if (res.statusCode !== 200) return complete(new Error(`CDP HTTP status ${res.statusCode}`));
|
||||||
|
const chunks = [];
|
||||||
|
let bytes = 0;
|
||||||
|
res.on("data", (chunk) => {
|
||||||
|
if (done || settled) return;
|
||||||
|
bytes += chunk.length;
|
||||||
|
if (bytes > 16 * 1024) return complete(new Error("CDP response exceeds 16 KiB"));
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
res.on("end", () => {
|
||||||
|
try {
|
||||||
|
const info = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||||
|
const endpoint = new URL(info.webSocketDebuggerUrl);
|
||||||
|
if (endpoint.protocol !== "ws:" || endpoint.hostname !== "127.0.0.1" || endpoint.port !== String(port) ||
|
||||||
|
endpoint.username || endpoint.password || endpoint.search || endpoint.hash ||
|
||||||
|
endpoint.href !== `ws://127.0.0.1:${port}${endpoint.pathname}` ||
|
||||||
|
!/^\/devtools\/browser\/[A-Za-z0-9-]+$/.test(endpoint.pathname)) {
|
||||||
|
throw new Error("CDP response has an invalid browser WebSocket endpoint");
|
||||||
|
}
|
||||||
|
complete(null, endpoint.href);
|
||||||
|
} catch (error) { complete(error); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
activeRequest = request;
|
||||||
|
request.on("error", (error) => complete(error));
|
||||||
|
attemptTimer = setTimeout(() => complete(new Error("CDP HTTP request timed out")), requestTimeoutMs);
|
||||||
|
};
|
||||||
|
signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
if (signal?.aborted) abort();
|
||||||
|
else attempt();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { waitForBrowserService };
|
||||||
+18
-7
@@ -64,13 +64,24 @@ function serverEntry() {
|
|||||||
// <this binary> --browser-service
|
// <this binary> --browser-service
|
||||||
// and connects over CDP (system Chrome stays the fallback). Handshake:
|
// and connects over CDP (system Chrome stays the fallback). Handshake:
|
||||||
// the helper prints "BROWSER_SERVICE_CDP <port>" on stdout once the CDP
|
// the helper prints "BROWSER_SERVICE_CDP <port>" on stdout once the CDP
|
||||||
// endpoint listens. Lifetime: stdin stays open; parent death = EOF = quit.
|
// endpoint listens. Lifetime: parent IPC disconnect/death = quit.
|
||||||
// MUST run before the single-instance lock — a helper is a second
|
// MUST run before the single-instance lock — a helper is a second
|
||||||
// instance of this very binary, and before the LH_DEV_URL redirect — the
|
// instance of this very binary, and before the LH_DEV_URL redirect — the
|
||||||
// helper owns no user data at all.
|
// helper owns no user data at all.
|
||||||
if (process.argv.includes("--browser-service")) {
|
if (process.argv.includes("--browser-service")) {
|
||||||
|
const { waitForBrowserService } = require("./browser-service-readiness.cjs");
|
||||||
|
// Own the helper's entire startup lifetime, including the readiness wait.
|
||||||
|
const lifetime = new AbortController();
|
||||||
|
const parentClosed = () => {
|
||||||
|
lifetime.abort(new Error("browser-service parent IPC disconnected"));
|
||||||
|
app.quit();
|
||||||
|
};
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
// Full Windows Electron replaces stdin with synthetic EOF. Use the
|
||||||
|
// real Node IPC channel, and refuse to start an unowned helper.
|
||||||
|
if (!process.channel || !process.connected) throw new Error("browser-service requires connected parent IPC");
|
||||||
|
process.once("disconnect", parentClosed);
|
||||||
app.dock?.hide();
|
app.dock?.hide();
|
||||||
const port = await freePort();
|
const port = await freePort();
|
||||||
// Must land before app-ready for Chromium to pick it up.
|
// Must land before app-ready for Chromium to pick it up.
|
||||||
@@ -80,13 +91,13 @@ if (process.argv.includes("--browser-service")) {
|
|||||||
// platform (no window-all-closed handler is installed here).
|
// platform (no window-all-closed handler is installed here).
|
||||||
const win = new BrowserWindow({ show: false });
|
const win = new BrowserWindow({ show: false });
|
||||||
await win.loadURL("about:blank");
|
await win.loadURL("about:blank");
|
||||||
console.log(`BROWSER_SERVICE_CDP ${port}`);
|
// Window load is not a DevTools HTTP readiness barrier. Confirm the
|
||||||
// Parent-tied lifetime: the server holds our stdin open.
|
// endpoint before both production and smoke clients see the handshake.
|
||||||
process.stdin.resume();
|
await waitForBrowserService(port, { signal: lifetime.signal });
|
||||||
process.stdin.on("end", () => app.quit());
|
if (!lifetime.signal.aborted) console.log(`BROWSER_SERVICE_CDP ${port}`);
|
||||||
process.stdin.on("close", () => app.quit());
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`browser-service failed: ${err?.stack ?? err}`);
|
if (lifetime.signal.aborted) return; // Parent disconnect is normal.
|
||||||
|
console.error("browser-service failed:", err);
|
||||||
app.exit(1);
|
app.exit(1);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ const nextConfig: NextConfig = {
|
|||||||
// caught it; unit tests run unbundled and never see it.
|
// caught it; unit tests run unbundled and never see it.
|
||||||
outputFileTracingIncludes: {
|
outputFileTracingIncludes: {
|
||||||
"*": [
|
"*": [
|
||||||
|
// The standalone trace omitted this entire runtime-imported directory
|
||||||
|
// in 0.3.0, so importing puppeteer-core failed before browser startup.
|
||||||
|
"node_modules/@puppeteer/browsers/lib/browser-data/**/*.js",
|
||||||
"node_modules/pdfjs-dist/legacy/**/*",
|
"node_modules/pdfjs-dist/legacy/**/*",
|
||||||
"node_modules/pdfjs-dist/standard_fonts/**/*",
|
"node_modules/pdfjs-dist/standard_fonts/**/*",
|
||||||
// Wave E (2026-10-10): pdf_render paints via @napi-rs/canvas — its
|
// Wave E (2026-10-10): pdf_render paints via @napi-rs/canvas — its
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.3.0",
|
"version": "0.3.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.3.0",
|
"version": "0.3.2",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-agent-core": "1.0.2",
|
"@earendil-works/pi-agent-core": "1.0.2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.3.0",
|
"version": "0.3.2",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Amos Blomqvist",
|
"name": "Amos Blomqvist",
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* Import the demo dependency graph from ONLY a standalone payload. With --browser,
|
||||||
|
* also drive the packaged app's own --browser-service (never system Chrome).
|
||||||
|
* No server, model, keys, installed app UI, or production diagnostic route needed.
|
||||||
|
*
|
||||||
|
* node scripts/smoke-demo-runtime.mjs --standalone <dir> --runtime <electron binary> [--browser]
|
||||||
|
* --runtime defaults to host Node for the import-only gate.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import { createRequire, registerHooks } from "node:module";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
/** @param {Record<string, string | undefined>} env @param {string} home @returns {Record<string, string | undefined>} */
|
||||||
|
export function probeEnv(env, home) {
|
||||||
|
const clean = Object.fromEntries(Object.entries(env).filter(([key]) =>
|
||||||
|
!/KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL/i.test(key) &&
|
||||||
|
!/^(AWS_|GOOGLE_|GCLOUD_|CLOUDSDK_|AZURE_|ANTHROPIC_|OPENAI_|OPENROUTER_|GEMINI_)/.test(key) &&
|
||||||
|
!["NODE_OPTIONS", "NODE_PATH", "LH_DEV_URL", "ELECTRON_RUN_AS_NODE"].includes(key)));
|
||||||
|
return {
|
||||||
|
...clean,
|
||||||
|
HOME: home, USERPROFILE: home,
|
||||||
|
APPDATA: path.join(home, "config"), LOCALAPPDATA: path.join(home, "local"),
|
||||||
|
XDG_CONFIG_HOME: path.join(home, "config"), XDG_CACHE_HOME: path.join(home, "cache"),
|
||||||
|
LH_DATA_DIR: path.join(home, "data"), LH_AUTH_FILE: path.join(home, "auth.json"),
|
||||||
|
LH_IGNORE_ENV_KEYS: "1", NODE_ENV: "production",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep fetch's nested cause (including AggregateError socket failures), but
|
||||||
|
// never dump arbitrary error properties such as request headers or environment.
|
||||||
|
export function formatProbeError(error, depth = 0) {
|
||||||
|
if (depth > 5) return "[cause chain truncated]";
|
||||||
|
if (!error || typeof error !== "object") return String(error);
|
||||||
|
const lines = [typeof error.stack === "string" ? error.stack : `${error.name ?? "Error"}: ${error.message ?? "unknown error"}`];
|
||||||
|
for (const field of ["code", "errno", "syscall", "address", "port"]) {
|
||||||
|
if (["string", "number"].includes(typeof error[field])) lines.push(`${field}: ${error[field]}`);
|
||||||
|
}
|
||||||
|
if (error.cause !== undefined) lines.push(`cause: ${formatProbeError(error.cause, depth + 1)}`);
|
||||||
|
if (Array.isArray(error.errors)) {
|
||||||
|
for (const nested of error.errors.slice(0, 8)) lines.push(`aggregate: ${formatProbeError(nested, depth + 1)}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyDemoRuntime(standalone, runtime = process.execPath, browser = false) {
|
||||||
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-demo-runtime-"));
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(path.join(home, "auth.json"), "{}\n", { mode: 0o600 });
|
||||||
|
const result = spawnSync(runtime, [fileURLToPath(import.meta.url), "--probe", path.resolve(standalone), ...(browser ? ["--browser"] : [])], {
|
||||||
|
cwd: home,
|
||||||
|
env: { ...probeEnv(process.env, home), ELECTRON_RUN_AS_NODE: "1" },
|
||||||
|
encoding: "utf8", timeout: 60_000, maxBuffer: 128 * 1024,
|
||||||
|
});
|
||||||
|
if (result.status !== 0 || result.error) {
|
||||||
|
throw new Error(`demo runtime probe failed (${result.error?.message ?? result.status}):\n${result.stdout ?? ""}${result.stderr ?? ""}`);
|
||||||
|
}
|
||||||
|
process.stdout.write(result.stdout);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPayload(standalone) {
|
||||||
|
const root = fs.realpathSync(standalone);
|
||||||
|
const inside = (file) => {
|
||||||
|
const relative = path.relative(root, fs.realpathSync(file));
|
||||||
|
assert.ok(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative),
|
||||||
|
`dependency escaped standalone payload: ${file}`);
|
||||||
|
};
|
||||||
|
const require = createRequire(path.join(root, "server.js"));
|
||||||
|
const entry = require.resolve("puppeteer-core");
|
||||||
|
inside(entry); // require.resolve must not silently fall back to checkout node_modules.
|
||||||
|
const loaded = new Set();
|
||||||
|
const hooks = registerHooks({
|
||||||
|
resolve(specifier, context, nextResolve) {
|
||||||
|
const result = nextResolve(specifier, context);
|
||||||
|
if (result.url.startsWith("file:")) {
|
||||||
|
inside(fileURLToPath(result.url));
|
||||||
|
loaded.add(result.url);
|
||||||
|
} else {
|
||||||
|
assert.ok(result.url.startsWith("node:"), `unexpected dependency URL: ${result.url}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const { default: puppeteer } = await import(pathToFileURL(entry).href);
|
||||||
|
assert.equal(typeof puppeteer.connect, "function");
|
||||||
|
console.log(`[smoke-demo-runtime] puppeteer-core import ok; ${loaded.size} modules confined to ${root}`);
|
||||||
|
return { puppeteer, hooks };
|
||||||
|
} catch (error) {
|
||||||
|
hooks.deregister();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit IPC disconnect can omit ChildProcess 'close' even after Electron
|
||||||
|
// exits. Wait for actual exit, boundedly; never infer shutdown intent from
|
||||||
|
// Node's automatic stdin cleanup after an unexpected child exit.
|
||||||
|
/** @returns {Promise<{code: number | null, signal: NodeJS.Signals | null, requested: boolean}>} */
|
||||||
|
export async function stopBrowserService(helper) {
|
||||||
|
if (!helper.pid || helper.exitCode !== null || helper.signalCode !== null) {
|
||||||
|
return { code: helper.exitCode, signal: helper.signalCode, requested: false };
|
||||||
|
}
|
||||||
|
let onExit, onError;
|
||||||
|
const exited = new Promise((resolve, reject) => {
|
||||||
|
onExit = (code, signal) => resolve({ code, signal });
|
||||||
|
onError = reject;
|
||||||
|
helper.once("exit", onExit);
|
||||||
|
helper.once("error", onError);
|
||||||
|
});
|
||||||
|
const wait = async (ms) => {
|
||||||
|
let timer;
|
||||||
|
try {
|
||||||
|
return await Promise.race([exited, new Promise((resolve) => { timer = setTimeout(() => resolve(null), ms); })]);
|
||||||
|
} finally { clearTimeout(timer); }
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const requested = helper.connected;
|
||||||
|
if (requested) {
|
||||||
|
console.log("[smoke-demo-runtime] requesting parent IPC disconnect");
|
||||||
|
helper.disconnect();
|
||||||
|
}
|
||||||
|
const result = await wait(5000);
|
||||||
|
if (!result) {
|
||||||
|
helper.kill("SIGKILL");
|
||||||
|
if (!await wait(3000)) throw new Error("browser-service did not exit after forced cleanup");
|
||||||
|
throw new Error("browser-service did not exit after IPC disconnect; force-killed");
|
||||||
|
}
|
||||||
|
return { ...result, requested };
|
||||||
|
} finally {
|
||||||
|
helper.removeListener("exit", onExit);
|
||||||
|
helper.removeListener("error", onError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function browserProbe(puppeteer) {
|
||||||
|
assert.ok(process.versions.electron, "--browser requires a packaged Electron --runtime binary");
|
||||||
|
const home = process.env.HOME;
|
||||||
|
const env = { ...process.env };
|
||||||
|
delete env.ELECTRON_RUN_AS_NODE;
|
||||||
|
delete env.LH_DEV_URL;
|
||||||
|
const helper = spawn(process.execPath, [
|
||||||
|
"--browser-service", `--user-data-dir=${path.join(home, "browser-profile")}`,
|
||||||
|
"--disable-background-networking", "--disable-component-update", "--no-proxy-server",
|
||||||
|
// Hosted Linux runners disallow Chromium's unprivileged sandbox.
|
||||||
|
...(process.platform === "linux" ? ["--no-sandbox"] : []),
|
||||||
|
], { cwd: home, env, stdio: ["ignore", "pipe", "pipe", "ipc"] });
|
||||||
|
let output = "";
|
||||||
|
let errors = "";
|
||||||
|
helper.stderr.on("data", (chunk) => { errors = (errors + chunk).slice(-16_000); });
|
||||||
|
let browser;
|
||||||
|
let failed = false;
|
||||||
|
try {
|
||||||
|
const port = await new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error("browser-service handshake timed out")), 15_000);
|
||||||
|
helper.once("error", (err) => { clearTimeout(timer); reject(err); });
|
||||||
|
helper.once("exit", (code) => { clearTimeout(timer); reject(new Error(`browser-service exited early: ${code}`)); });
|
||||||
|
helper.stdout.on("data", (chunk) => {
|
||||||
|
output = (output + chunk).slice(-16_000);
|
||||||
|
const match = output.match(/^BROWSER_SERVICE_CDP (\d+)\r?$/m);
|
||||||
|
if (match) { clearTimeout(timer); resolve(Number(match[1])); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const info = await (await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(5000) })).json();
|
||||||
|
browser = await puppeteer.connect({ browserWSEndpoint: info.webSocketDebuggerUrl, defaultViewport: null, protocolTimeout: 10_000 });
|
||||||
|
// Electron CDP has no Target.createTarget: use the helper's existing page.
|
||||||
|
const pages = await browser.pages();
|
||||||
|
const page = pages.find((candidate) => candidate.target().type() === "page");
|
||||||
|
assert.ok(page, "browser-service has no page target");
|
||||||
|
const failures = [];
|
||||||
|
page.on("console", (msg) => { if (msg.type() === "error") failures.push(msg.text()); });
|
||||||
|
page.on("pageerror", (err) => failures.push(String(err)));
|
||||||
|
page.on("requestfailed", (req) => failures.push(`failed request: ${req.url()}`));
|
||||||
|
await page.setRequestInterception(true);
|
||||||
|
page.on("request", (req) => {
|
||||||
|
if (/^(file:|about:|data:)/.test(req.url())) void req.continue();
|
||||||
|
else { failures.push(`unexpected network request: ${req.url()}`); void req.abort(); }
|
||||||
|
});
|
||||||
|
const html = path.join(home, "demo.html");
|
||||||
|
fs.writeFileSync(html, '<!doctype html><meta charset="utf-8"><button id="increment">Increment</button><output id="count">0</output><script>document.querySelector("#increment").onclick = () => { document.querySelector("#count").textContent = "1"; };</script>');
|
||||||
|
await page.goto(pathToFileURL(html).href, { waitUntil: "load", timeout: 10_000 });
|
||||||
|
assert.equal(await page.$eval("#count", (element) => element.textContent), "0");
|
||||||
|
await page.click("#increment");
|
||||||
|
assert.equal(await page.$eval("#count", (element) => element.textContent), "1");
|
||||||
|
await page.goto("about:blank");
|
||||||
|
assert.equal(await page.$("#increment"), null, "page state was not cleared");
|
||||||
|
assert.deepEqual(failures, [], "demo console/page/network errors");
|
||||||
|
console.log("[smoke-demo-runtime] packaged browser-service: JS button 0 → 1, clean console/page, page reset ok (no Chrome fallback)");
|
||||||
|
} catch (err) {
|
||||||
|
failed = true;
|
||||||
|
throw new Error(`browser-service probe failed at local CDP/browser interaction\n--- browser-service output ---\n${output}\n${errors}`, { cause: err });
|
||||||
|
} finally {
|
||||||
|
browser?.disconnect();
|
||||||
|
try {
|
||||||
|
const { code, signal, requested } = await stopBrowserService(helper);
|
||||||
|
if (requested) console.log(`[smoke-demo-runtime] observed helper exit after requested IPC disconnect: code=${code}, signal=${signal} (no resident helper)`);
|
||||||
|
else console.log(`[smoke-demo-runtime] helper already exited/disconnected before cleanup: code=${code}, signal=${signal}; no shutdown requested`);
|
||||||
|
if (!failed) {
|
||||||
|
assert.ok(requested, "browser-service exited/disconnected before requested cleanup");
|
||||||
|
assert.deepEqual({ code, signal }, { code: 0, signal: null }, "browser-service shutdown failed");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!failed) throw error;
|
||||||
|
console.error(`[smoke-demo-runtime] cleanup also failed: ${formatProbeError(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args[0] === "--probe") {
|
||||||
|
const { puppeteer, hooks } = await importPayload(args[1]);
|
||||||
|
try {
|
||||||
|
if (args.includes("--browser")) await browserProbe(puppeteer);
|
||||||
|
} finally { hooks.deregister(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = (name, fallback) => {
|
||||||
|
const index = args.indexOf(name);
|
||||||
|
if (index < 0) return fallback;
|
||||||
|
assert.ok(args[index + 1] && !args[index + 1].startsWith("--"), `${name} requires a path`);
|
||||||
|
return path.resolve(args[index + 1]);
|
||||||
|
};
|
||||||
|
verifyDemoRuntime(value("--standalone", path.resolve(import.meta.dirname, "../.next/standalone")), value("--runtime", process.execPath), args.includes("--browser"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
main().catch((err) => { console.error(`[smoke-demo-runtime] FAIL: ${formatProbeError(err)}`); process.exitCode = 1; });
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
* way, same day).
|
* way, same day).
|
||||||
*/
|
*/
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
import { probeEnv, verifyDemoRuntime } from "./smoke-demo-runtime.mjs";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as http from "node:http";
|
import * as http from "node:http";
|
||||||
@@ -76,6 +77,9 @@ function get(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
// HTTP boot alone never imports the dynamically loaded demo engine.
|
||||||
|
// Fail before packaging if its standalone dependency graph is incomplete.
|
||||||
|
verifyDemoRuntime(STANDALONE, RUNTIME);
|
||||||
const port = await freePort();
|
const port = await freePort();
|
||||||
const url = `http://127.0.0.1:${port}`;
|
const url = `http://127.0.0.1:${port}`;
|
||||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-smoke-"));
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-smoke-"));
|
||||||
@@ -104,7 +108,7 @@ async function main() {
|
|||||||
const child = spawn(RUNTIME, [SERVER], {
|
const child = spawn(RUNTIME, [SERVER], {
|
||||||
cwd: STANDALONE,
|
cwd: STANDALONE,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...probeEnv(process.env, home),
|
||||||
...(ELECTRON_MODE ? { ELECTRON_RUN_AS_NODE: "1" } : {}),
|
...(ELECTRON_MODE ? { ELECTRON_RUN_AS_NODE: "1" } : {}),
|
||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
PORT: String(port),
|
PORT: String(port),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ The defining condition of practice: the proposition you are exercising is **inac
|
|||||||
Three things this session is not:
|
Three things this session is not:
|
||||||
|
|
||||||
- **Not an exam.** An exam question is built to produce evidence about the learner at the cheapest gradable demand. You build the demand that strengthens the most.
|
- **Not an exam.** An exam question is built to produce evidence about the learner at the cheapest gradable demand. You build the demand that strengthens the most.
|
||||||
- **Not a teaching session.** You will find misconceptions. Repair them in a line, surgically, and return to practice. A practice session that drifts into re-teaching has stopped practicing.
|
- **Not a teaching session.** You will find misconceptions. Address them with minimal, surgical nudges, over as many exchanges as needed. A practice session that drifts into re-teaching has stopped practicing.
|
||||||
- **Not search training.** Long competition-shaped problems with concealed paths train a meta-skill of search control — a different product. Your metric is decision points per minute at the edge of the learner's ability, not volume of search.
|
- **Not search training.** Long competition-shaped problems with concealed paths train a meta-skill of search control — a different product. Your metric is decision points per minute at the edge of the learner's ability, not volume of search.
|
||||||
|
|
||||||
Teaching's product is an event — the click. Yours is a state: quiet availability, the proposition deploying without assistance in situations unlike the lesson's. Nothing announces itself when this happens. Do not engineer aha-moments; accumulate deployments.
|
Teaching's product is an event — the click. Yours is a state: quiet availability, the proposition deploying without assistance in situations unlike the lesson's. Nothing announces itself when this happens. Do not engineer aha-moments; accumulate deployments.
|
||||||
@@ -73,7 +73,7 @@ Interleaving is the default. Never issue blocks of same-target problems.
|
|||||||
|
|
||||||
1. **Target** — per the selection rules.
|
1. **Target** — per the selection rules.
|
||||||
2. **Mode** — the cheapest undemonstrated layer for that target.
|
2. **Mode** — the cheapest undemonstrated layer for that target.
|
||||||
3. **Difficulty** — from the last outcomes on this target: clean success, one notch up; success that needed hints, hold; a miss, one notch down and consider dropping a mode; two misses in a row, switch targets.
|
3. **Difficulty** — from the last outcomes on this target: clean success, one notch up; success that needed hints, hold; a miss, one notch down and consider dropping a mode. Two missed concluded problems in a row on this target: switch targets for the next demand. Never switch away from an active problem because of the number of attempts.
|
||||||
4. **Situation** — a cue class not yet used for this target in this session; the surface, theory-formal or applied-concrete, is the learner's preference and changes nothing structural.
|
4. **Situation** — a cue class not yet used for this target in this session; the surface, theory-formal or applied-concrete, is the learner's preference and changes nothing structural.
|
||||||
5. **Commit** — before the learner sees anything, fix the model solution with its moves in order (and hold the target and mode in mind). Committing before posing is what keeps verdicts honest, hints consistent, and the solution from leaking before it exists in full.
|
5. **Commit** — before the learner sees anything, fix the model solution with its moves in order (and hold the target and mode in mind). Committing before posing is what keeps verdicts honest, hints consistent, and the solution from leaking before it exists in full.
|
||||||
6. **Check** — solve the problem, then try to break it. A problem that can be solved by a route skipping the target teaches that route instead, and is unsound; fix it or discard it. Confirm the solution requires nothing beyond the material. Never pose what you have not solved and shortcut-hunted.
|
6. **Check** — solve the problem, then try to break it. A problem that can be solved by a route skipping the target teaches that route instead, and is unsound; fix it or discard it. Confirm the solution requires nothing beyond the material. Never pose what you have not solved and shortcut-hunted.
|
||||||
@@ -101,7 +101,7 @@ Three structural rules:
|
|||||||
|
|
||||||
- **The stall is not always where the learner's attention is.** A trace grinding at execution on the wrong proposition has answered *which?* wrongly — the first failure is earlier than the felt stuckness. The minimal response is a **verification nudge**: "check the assumption behind the move you're making." Point at the error; never state the correction while the learner can still generate it.
|
- **The stall is not always where the learner's attention is.** A trace grinding at execution on the wrong proposition has answered *which?* wrongly — the first failure is earlier than the felt stuckness. The minimal response is a **verification nudge**: "check the assumption behind the move you're making." Point at the error; never state the correction while the learner can still generate it.
|
||||||
- **Statement repair is free; solution information is metered.** "I don't understand what's being asked" is a parse failure, not a stall on the material — repair the statement fully and immediately. Wording is logistics, and logistics are yours.
|
- **Statement repair is free; solution information is metered.** "I don't understand what's being asked" is a parse failure, not a stall on the material — repair the statement fully and immediately. Wording is logistics, and logistics are yours.
|
||||||
- **The escape valve.** Minimality does not mean withholding help from a learner who cannot proceed. On explicit surrender, or when the trace shows genuine inability, drop straight to R5 and reveal. Then mark the item for a fresh-surface retest before the session ends: revealed information is re-purchased as a retrieval event later.
|
- When a nudge does not land, reassess where the learner is stuck and offer only the next necessary piece of information. Do not jump to the full solution. Tentative answers, questions, and thinking aloud remain work in progress.
|
||||||
|
|
||||||
The rung a problem needed is a difficulty readout — only R0–R1 means the problem was about right; R3 and above means it was over the edge. Let this feed the next demand's difficulty.
|
The rung a problem needed is a difficulty readout — only R0–R1 means the problem was about right; R3 and above means it was over the edge. Let this feed the next demand's difficulty.
|
||||||
|
|
||||||
@@ -109,17 +109,17 @@ The rung a problem needed is a difficulty readout — only R0–R1 means the pro
|
|||||||
|
|
||||||
The thinking trace carries junk by design. Extract **moves**, not a grade: which propositions the learner used, in what order, and where it broke.
|
The thinking trace carries junk by design. Extract **moves**, not a grade: which propositions the learner used, in what order, and where it broke.
|
||||||
|
|
||||||
**On a wrong answer**, state the verdict plainly, and do not give the correction. Localize and demand the repair: "moves one and two are fine; something breaks at three; find it." One bounded attempt. A correction found by the learner after a confident error consolidates more than any explanation you could give — revealing it instantly destroys exactly that yield. If the repair attempt fails, reveal and move on.
|
**On a wrong answer**, state the verdict plainly and localize the first break without giving the correction. Give the smallest nudge that lets the learner make the next move, then wait. If that nudge does not land, give another minimal nudge based on the new thinking. A failed attempt is never a reason to reveal the correction or conclude the problem. Continue until the learner finds and completes the correct solution.
|
||||||
|
|
||||||
**On a right answer**, confirm it and credit the load-bearing moves — name what made it work, not only that it worked. One line.
|
**On a right answer**, confirm it and credit the load-bearing moves — name what made it work, not only that it worked. One line.
|
||||||
|
|
||||||
Classify every miss: cue miss (nothing came), discrimination error (wrong candidate won), execution slip (right proposition, wrong run), careless, or structural (the retrieved proposition is itself wrong — a misconception). The first four are practice information. Structural gets one surgical line — the correction, stated once — and no re-teaching; if a repair needs more than a line, this session is the wrong instrument, and you say so.
|
Classify every miss: cue miss (nothing came), discrimination error (wrong candidate won), execution slip (right proposition, wrong run), careless, or structural (the retrieved proposition is itself wrong — a misconception). The first four are practice information. Structural misses call for minimal nudges toward repairing the misconception, not an immediate correction or re-teaching. Keep each nudge surgical; do not impose a one-line or one-attempt limit on the repair as a whole.
|
||||||
|
|
||||||
## VIII. Progression
|
## VIII. Progression
|
||||||
|
|
||||||
A claimed answer is not a signal to progress. You own progression, not the answer count. A problem may take many claims; staying is the default while yield remains. Concluding ends your turn; the next problem is posed only when the learner explicitly moves on — the pause between problems is theirs to use (questions, repairs, clarifications) and theirs to end.
|
A claimed answer is not a signal to progress. You own progression, not the answer count. A problem may take many claims; staying is the default while yield remains. Concluding ends your turn; the next problem is posed only when the learner explicitly moves on — the pause between problems is theirs to use (questions, repairs, clarifications) and theirs to end.
|
||||||
|
|
||||||
Move on when the problem is solved correctly; when the learner surrenders or cannot proceed; or when the marginal yield of staying is exhausted — flailing, repeated misses, frustration. Moving on spends a reveal: the committed solution becomes visible to the learner when the next problem is assigned. Do not spend reveals for nothing, and do not withhold them past the point of exhaustion.
|
Conclude when the learner has solved the problem correctly or explicitly asks to end it. Do not infer permission to end it from mistakes, elapsed time, the number of attempts, frustration, or an apparent inability to proceed.
|
||||||
|
|
||||||
## IX. The arc
|
## IX. The arc
|
||||||
|
|
||||||
|
|||||||
@@ -116,8 +116,9 @@ const NO_CHROME_MESSAGE =
|
|||||||
// Demos execute JS and need a browser. PRIMARY: this very app's binary
|
// Demos execute JS and need a browser. PRIMARY: this very app's binary
|
||||||
// spawned with --browser-service (main.cjs boots a hidden window with a CDP
|
// spawned with --browser-service (main.cjs boots a hidden window with a CDP
|
||||||
// port and prints "BROWSER_SERVICE_CDP <port>"). FALLBACK: system Chrome.
|
// port and prints "BROWSER_SERVICE_CDP <port>"). FALLBACK: system Chrome.
|
||||||
// The helper's stdin is held open by this process; server death = EOF = the
|
// A Node IPC channel owns its lifetime: parent death/disconnect quits the
|
||||||
// helper quits — no orphans. PDFs and SVGs never come here (native now).
|
// helper on every OS (Windows Electron stdin is synthetic EOF). No orphans.
|
||||||
|
// PDFs and SVGs never come here (native now).
|
||||||
|
|
||||||
/** Handshake line parser (pure; exported for tests). */
|
/** Handshake line parser (pure; exported for tests). */
|
||||||
export function parseBrowserServiceLine(line: string): number | null {
|
export function parseBrowserServiceLine(line: string): number | null {
|
||||||
@@ -180,7 +181,7 @@ async function connectBrowserService(): Promise<import("puppeteer-core").Browser
|
|||||||
// Cast: the sanitizer is pure over a loose record; NODE_ENV is
|
// Cast: the sanitizer is pure over a loose record; NODE_ENV is
|
||||||
// always present in a real process.env.
|
// always present in a real process.env.
|
||||||
env: browserServiceSpawnEnv(process.env) as NodeJS.ProcessEnv,
|
env: browserServiceSpawnEnv(process.env) as NodeJS.ProcessEnv,
|
||||||
stdio: ["pipe", "pipe", "pipe"] as const,
|
stdio: ["ignore", "pipe", "pipe", "ipc"] as const,
|
||||||
});
|
});
|
||||||
browserServiceProc = proc;
|
browserServiceProc = proc;
|
||||||
port = await new Promise<number | null>((resolve) => {
|
port = await new Promise<number | null>((resolve) => {
|
||||||
@@ -197,7 +198,7 @@ async function connectBrowserService(): Promise<import("puppeteer-core").Browser
|
|||||||
resolve(parseBrowserServiceLine(line));
|
resolve(parseBrowserServiceLine(line));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
proc.stdout.on("data", onData);
|
proc.stdout!.on("data", onData);
|
||||||
proc.once("exit", () => {
|
proc.once("exit", () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
resolve(null);
|
resolve(null);
|
||||||
@@ -215,7 +216,7 @@ async function connectBrowserService(): Promise<import("puppeteer-core").Browser
|
|||||||
const info = (await version.json()) as { webSocketDebuggerUrl?: string };
|
const info = (await version.json()) as { webSocketDebuggerUrl?: string };
|
||||||
if (!info.webSocketDebuggerUrl) return null;
|
if (!info.webSocketDebuggerUrl) return null;
|
||||||
// connect ≠ launch: browser.close() below merely disconnects — the
|
// connect ≠ launch: browser.close() below merely disconnects — the
|
||||||
// helper stays resident for the next job (stdin-held lifetime).
|
// helper stays resident for the next job (IPC-owned lifetime).
|
||||||
return await puppeteer.connect({
|
return await puppeteer.connect({
|
||||||
browserWSEndpoint: info.webSocketDebuggerUrl,
|
browserWSEndpoint: info.webSocketDebuggerUrl,
|
||||||
defaultViewport: null,
|
defaultViewport: null,
|
||||||
|
|||||||
@@ -202,8 +202,10 @@ export function concludeTool(gate: PracticeGate): AgentTool<typeof concludeInput
|
|||||||
label: "conclude",
|
label: "conclude",
|
||||||
description:
|
description:
|
||||||
"Conclude the active problem: records the outcome + verdict, reveals the committed solution, and frees " +
|
"Conclude the active problem: records the outcome + verdict, reveals the committed solution, and frees " +
|
||||||
"the learner's composer. The turn CONTINUES after conclude — reveal, then pose the next problem (or wrap " +
|
"the learner's composer. After the result, say your closing words about the learner's work if useful, then " +
|
||||||
"up in text). Nothing may conclude unless a problem is active.",
|
"END YOUR TURN — no further tool calls. The next problem is NEVER posed here: it waits for the learner's " +
|
||||||
|
"explicit go-ahead (a *Next problem* message, or their own words asking for the next one). Between problems, " +
|
||||||
|
"answer their questions about the concluded one. Nothing may conclude unless a problem is active.",
|
||||||
parameters: concludeInputObject,
|
parameters: concludeInputObject,
|
||||||
prepareArguments: (args: unknown) => healEncodedArguments(args, concludeInputObject) as never,
|
prepareArguments: (args: unknown) => healEncodedArguments(args, concludeInputObject) as never,
|
||||||
executionMode: "sequential",
|
executionMode: "sequential",
|
||||||
|
|||||||
@@ -325,6 +325,11 @@ const DEBRIS_RE = /[\b\t\n\v\f\r]/;
|
|||||||
* `\right`/`\rho`/`\rangle` corruption is common in display math). */
|
* `\right`/`\rho`/`\rangle` corruption is common in display math). */
|
||||||
const DISPLAY_DEBRIS_RE = /[\b\t\v\f\r]/;
|
const DISPLAY_DEBRIS_RE = /[\b\t\v\f\r]/;
|
||||||
|
|
||||||
|
/** Shared display-only repair. Keep raw arguments and model history unchanged. */
|
||||||
|
export function repairMathForDisplay(text: string): string {
|
||||||
|
return healOverescapedCommands(restoreMathEscapes(text));
|
||||||
|
}
|
||||||
|
|
||||||
export function restoreMathEscapes(text: string): string {
|
export function restoreMathEscapes(text: string): string {
|
||||||
if (!DEBRIS_RE.test(text)) return text;
|
if (!DEBRIS_RE.test(text)) return text;
|
||||||
return restoreDollarMathEscapes(restoreLegacyMathEscapes(text));
|
return restoreDollarMathEscapes(restoreLegacyMathEscapes(text));
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
import { Value } from "typebox/value";
|
import { Value } from "typebox/value";
|
||||||
import type { TSchema } from "typebox";
|
import type { TSchema } from "typebox";
|
||||||
import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
|
import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
|
||||||
import { restoreMathEscapes, healOverescapedCommands } from "./math-delims.ts";
|
import { repairMathForDisplay } from "./math-delims.ts";
|
||||||
import { healEncodedArguments } from "./argument-heal.ts";
|
import { healEncodedArguments } from "./argument-heal.ts";
|
||||||
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
|
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ function boundaryParse<T>(schema: TSchema, input: unknown): T | undefined {
|
|||||||
* plus "nabla" as letters), so even runs before a command start inside
|
* plus "nabla" as letters), so even runs before a command start inside
|
||||||
* balanced math spans collapse to one backslash. */
|
* balanced math spans collapse to one backslash. */
|
||||||
function restoreMathEscapesDeep(value: unknown): unknown {
|
function restoreMathEscapesDeep(value: unknown): unknown {
|
||||||
if (typeof value === "string") return healOverescapedCommands(restoreMathEscapes(value));
|
if (typeof value === "string") return repairMathForDisplay(value);
|
||||||
if (Array.isArray(value)) return value.map(restoreMathEscapesDeep);
|
if (Array.isArray(value)) return value.map(restoreMathEscapesDeep);
|
||||||
if (typeof value === "object" && value !== null) {
|
if (typeof value === "object" && value !== null) {
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/** Execute the real helper entry with fake Electron; no display or user profile. */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { setImmediate as turn } from "node:timers/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { runInNewContext } from "node:vm";
|
||||||
|
import { stopBrowserService } from "../scripts/smoke-demo-runtime.mjs";
|
||||||
|
|
||||||
|
const mainFile = fileURLToPath(new URL("../electron/main.cjs", import.meta.url));
|
||||||
|
const mainSource = readFileSync(mainFile, "utf8");
|
||||||
|
const requireMain = createRequire(mainFile);
|
||||||
|
|
||||||
|
function helperFixture(connected = true, channel = true) {
|
||||||
|
let allowAppReady!: () => void;
|
||||||
|
const appReady = new Promise<void>((resolve) => { allowAppReady = resolve; });
|
||||||
|
let allowReadiness!: () => void;
|
||||||
|
const readiness = new Promise<void>((resolve) => { allowReadiness = resolve; });
|
||||||
|
const state = { quits: 0, exits: [] as number[], windows: 0, readinessCalls: 0, stdinReads: 0, signal: undefined as AbortSignal | undefined };
|
||||||
|
const logs: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
// Match Electron 44's Windows override, not a native pipe.
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
stdin.push(null);
|
||||||
|
const process = Object.assign(new EventEmitter(), {
|
||||||
|
platform: "win32", argv: ["electron", "--browser-service"], env: {},
|
||||||
|
connected, channel: channel ? {} : undefined,
|
||||||
|
});
|
||||||
|
Object.defineProperty(process, "stdin", { get() { state.stdinReads++; return stdin; } });
|
||||||
|
const app = {
|
||||||
|
setName() {}, commandLine: { appendSwitch() {} }, whenReady: () => appReady,
|
||||||
|
quit() { state.quits++; }, exit(code: number) { state.exits.push(code); },
|
||||||
|
};
|
||||||
|
class FakeWindow {
|
||||||
|
constructor() { state.windows++; }
|
||||||
|
async loadURL(url: string) { assert.equal(url, "about:blank"); }
|
||||||
|
}
|
||||||
|
const net = {
|
||||||
|
createServer: () => Object.assign(new EventEmitter(), {
|
||||||
|
listen(_port: number, _host: string, callback: () => void) { callback(); },
|
||||||
|
address: () => ({ port: 54321 }), close(callback: () => void) { callback(); },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const waitForBrowserService = (_port: number, { signal }: { signal: AbortSignal }) => {
|
||||||
|
state.readinessCalls++;
|
||||||
|
state.signal = signal;
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (signal.aborted) return reject(signal.reason);
|
||||||
|
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
||||||
|
readiness.then(resolve);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
runInNewContext(mainSource, {
|
||||||
|
require: (id: string) => id === "electron" ? { app, BrowserWindow: FakeWindow }
|
||||||
|
: id === "node:net" ? net
|
||||||
|
: id === "./browser-service-readiness.cjs" ? { waitForBrowserService }
|
||||||
|
: requireMain(id),
|
||||||
|
process, __dirname: dirname(mainFile), AbortController,
|
||||||
|
console: { log: (line: string) => logs.push(line), error: (...parts: unknown[]) => errors.push(parts.join(" ")) },
|
||||||
|
}, { filename: mainFile });
|
||||||
|
return {
|
||||||
|
state, logs, errors, stdin, process, allowAppReady, allowReadiness,
|
||||||
|
disconnect() { process.connected = false; process.emit("disconnect"); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("browser-service IPC-owned lifetime", () => {
|
||||||
|
it("ignores synthetic Windows stdin EOF while IPC remains connected", async () => {
|
||||||
|
const helper = helperFixture();
|
||||||
|
helper.stdin.resume();
|
||||||
|
await turn();
|
||||||
|
assert.equal(helper.stdin.readableEnded, true);
|
||||||
|
assert.equal(helper.state.stdinReads, 0);
|
||||||
|
assert.equal(helper.state.quits, 0);
|
||||||
|
helper.allowAppReady();
|
||||||
|
await turn();
|
||||||
|
assert.equal(helper.state.readinessCalls, 1);
|
||||||
|
assert.equal(helper.state.signal?.aborted, false);
|
||||||
|
helper.allowReadiness();
|
||||||
|
await turn();
|
||||||
|
assert.deepEqual(helper.logs, ["BROWSER_SERVICE_CDP 54321"]);
|
||||||
|
assert.deepEqual(helper.state.exits, []);
|
||||||
|
helper.disconnect();
|
||||||
|
assert.equal(helper.state.quits, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disconnect during readiness aborts it, quits once, and never announces", async () => {
|
||||||
|
const helper = helperFixture();
|
||||||
|
helper.allowAppReady();
|
||||||
|
await turn();
|
||||||
|
assert.equal(helper.state.readinessCalls, 1);
|
||||||
|
helper.disconnect();
|
||||||
|
helper.disconnect();
|
||||||
|
await turn();
|
||||||
|
assert.equal(helper.state.signal?.aborted, true);
|
||||||
|
assert.equal(helper.state.quits, 1);
|
||||||
|
assert.deepEqual(helper.logs, []);
|
||||||
|
assert.deepEqual(helper.errors, []);
|
||||||
|
assert.deepEqual(helper.state.exits, []);
|
||||||
|
assert.equal(helper.state.stdinReads, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("owns disconnect before app.whenReady resolves", async () => {
|
||||||
|
const helper = helperFixture();
|
||||||
|
helper.disconnect();
|
||||||
|
await turn();
|
||||||
|
assert.equal(helper.state.quits, 1);
|
||||||
|
assert.equal(helper.state.windows, 0);
|
||||||
|
assert.deepEqual(helper.logs, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed without both a connected IPC channel and connection state", async () => {
|
||||||
|
for (const [connected, channel] of [[false, false], [true, false], [false, true]]) {
|
||||||
|
const helper = helperFixture(connected, channel);
|
||||||
|
await turn();
|
||||||
|
assert.deepEqual(helper.state.exits, [1]);
|
||||||
|
assert.match(helper.errors.join("\n"), /requires connected parent IPC/);
|
||||||
|
assert.equal(helper.state.windows, 0);
|
||||||
|
assert.equal(helper.state.readinessCalls, 0);
|
||||||
|
assert.equal(helper.state.stdinReads, 0);
|
||||||
|
assert.equal(helper.process.listenerCount("disconnect"), 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("browser-service probe cleanup", () => {
|
||||||
|
it("requests IPC disconnect and accepts actual exit without a close event", async () => {
|
||||||
|
const helper = Object.assign(new EventEmitter(), {
|
||||||
|
pid: 123, connected: true, exitCode: null as number | null, signalCode: null,
|
||||||
|
disconnect() {
|
||||||
|
helper.connected = false;
|
||||||
|
queueMicrotask(() => { helper.exitCode = 0; helper.emit("exit", 0, null); });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(await stopBrowserService(helper), { code: 0, signal: null, requested: true });
|
||||||
|
assert.equal(helper.listenerCount("exit"), 0);
|
||||||
|
assert.equal(helper.listenerCount("error"), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not label an already-exited helper as parent-requested shutdown", async () => {
|
||||||
|
const helper = { pid: 123, connected: false, exitCode: 0, signalCode: null,
|
||||||
|
disconnect() { assert.fail("must not request shutdown after early exit"); } };
|
||||||
|
assert.deepEqual(await stopBrowserService(helper), { code: 0, signal: null, requested: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/** Shipping helper readiness, with real loopback HTTP but no Electron/display. */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import * as http from "node:http";
|
||||||
|
import type { AddressInfo, Socket } from "node:net";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { setTimeout as delay } from "node:timers/promises";
|
||||||
|
import { waitForBrowserService } from "../electron/browser-service-readiness.cjs";
|
||||||
|
|
||||||
|
const endpoint = (port: number) => `ws://127.0.0.1:${port}/devtools/browser/test-browser-id`;
|
||||||
|
const version = (port: number) => JSON.stringify({ webSocketDebuggerUrl: endpoint(port) });
|
||||||
|
const short = { timeoutMs: 250, requestTimeoutMs: 50, retryMs: 10 };
|
||||||
|
|
||||||
|
async function withEndpoint(
|
||||||
|
reply: (request: http.IncomingMessage, response: http.ServerResponse, port: number) => void,
|
||||||
|
test: (port: number, idle: () => Promise<void>) => Promise<void>,
|
||||||
|
) {
|
||||||
|
let port = 0;
|
||||||
|
const sockets = new Set<Socket>();
|
||||||
|
const server = http.createServer((request, response) => reply(request, response, port));
|
||||||
|
server.on("connection", (socket) => {
|
||||||
|
sockets.add(socket);
|
||||||
|
socket.once("close", () => sockets.delete(socket));
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
port = (server.address() as AddressInfo).port;
|
||||||
|
const idle = async () => {
|
||||||
|
const deadline = Date.now() + 1000;
|
||||||
|
while (sockets.size && Date.now() < deadline) await delay(10);
|
||||||
|
assert.equal(sockets.size, 0, "readiness left an HTTP connection open");
|
||||||
|
};
|
||||||
|
try { await test(port, idle); }
|
||||||
|
finally {
|
||||||
|
server.closeAllConnections();
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("browser-service shipping readiness", () => {
|
||||||
|
it("waits for a TCP endpoint that starts listening after readiness began", async () => {
|
||||||
|
const server = http.createServer((_req, res) => res.end(version(port)));
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const port = (server.address() as AddressInfo).port;
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
let ready = false;
|
||||||
|
const waiting = waitForBrowserService(port, { timeoutMs: 2000, requestTimeoutMs: 500, retryMs: 10 }).then((value) => { ready = true; return value; });
|
||||||
|
try {
|
||||||
|
await delay(40);
|
||||||
|
assert.equal(ready, false, "announced readiness before the endpoint listened");
|
||||||
|
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
|
||||||
|
assert.equal(await waiting, endpoint(port));
|
||||||
|
} finally {
|
||||||
|
server.closeAllConnections();
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits through delayed/non-200 startup and only accepts the exact CDP endpoint", async () => {
|
||||||
|
let requests = 0;
|
||||||
|
await withEndpoint((req, res, port) => {
|
||||||
|
assert.equal(req.url, "/json/version");
|
||||||
|
assert.equal(req.headers.host, `127.0.0.1:${port}`);
|
||||||
|
if (++requests < 3) { res.writeHead(503); res.end(); }
|
||||||
|
else setTimeout(() => res.end(version(port)), 20);
|
||||||
|
}, async (port, idle) => {
|
||||||
|
assert.equal(await waitForBrowserService(port, { ...short, timeoutMs: 2000 }), endpoint(port));
|
||||||
|
assert.equal(requests, 3);
|
||||||
|
await idle();
|
||||||
|
await delay(40);
|
||||||
|
assert.equal(requests, 3, "polling continued after readiness");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries malformed JSON and a missing browser WebSocket endpoint", async () => {
|
||||||
|
let requests = 0;
|
||||||
|
await withEndpoint((_req, res, port) => {
|
||||||
|
requests++;
|
||||||
|
res.end(requests === 1 ? "not json" : requests === 2 ? "{}" : version(port));
|
||||||
|
}, async (port, idle) => {
|
||||||
|
assert.equal(await waitForBrowserService(port, { ...short, timeoutMs: 2000 }), endpoint(port));
|
||||||
|
assert.equal(requests, 3);
|
||||||
|
await idle();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects arbitrary, wrong-port, credentialed, and non-browser WebSocket URLs", async () => {
|
||||||
|
const invalid = [
|
||||||
|
"ws://example.com:1234/devtools/browser/id",
|
||||||
|
"ws://127.0.0.1:1/devtools/browser/id",
|
||||||
|
"wss://127.0.0.1:1234/devtools/browser/id",
|
||||||
|
"ws://user:password@127.0.0.1:1234/devtools/browser/id",
|
||||||
|
"ws://127.0.0.1:1234/devtools/page/id",
|
||||||
|
"ws://127.0.0.1:1234/devtools/browser/id?query=1",
|
||||||
|
"ws://127.0.0.1:1234/devtools/browser/id#fragment",
|
||||||
|
"ws://127.0.0.1:1234/devtools/browser/",
|
||||||
|
];
|
||||||
|
let requests = 0;
|
||||||
|
await withEndpoint((_req, res, port) => {
|
||||||
|
const bad = invalid[requests++];
|
||||||
|
res.end(bad ? JSON.stringify({ webSocketDebuggerUrl: bad.replaceAll(":1234", `:${port}`) }) : version(port));
|
||||||
|
}, async (port, idle) => {
|
||||||
|
assert.equal(await waitForBrowserService(port, { ...short, timeoutMs: 2000 }), endpoint(port));
|
||||||
|
assert.equal(requests, invalid.length + 1);
|
||||||
|
await idle();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds response size and retries instead of accepting oversized data", async () => {
|
||||||
|
let requests = 0;
|
||||||
|
await withEndpoint((_req, res, port) => {
|
||||||
|
res.end(++requests === 1 ? "x".repeat(16 * 1024 + 1) : version(port));
|
||||||
|
}, async (port, idle) => {
|
||||||
|
assert.equal(await waitForBrowserService(port, { ...short, timeoutMs: 2000 }), endpoint(port));
|
||||||
|
assert.equal(requests, 2);
|
||||||
|
await idle();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails within the readiness budget and retains the last HTTP failure", async () => {
|
||||||
|
let requests = 0;
|
||||||
|
await withEndpoint((_req, res) => { requests++; res.writeHead(503); res.end(); }, async (port, idle) => {
|
||||||
|
const start = Date.now();
|
||||||
|
await assert.rejects(waitForBrowserService(port, short), (error: Error) => {
|
||||||
|
assert.match(error.message, /not ready within 250ms/);
|
||||||
|
assert.match(String(error.cause), /CDP HTTP status 503/);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
assert.ok(Date.now() - start < 2000, "readiness exceeded its bounded budget");
|
||||||
|
await idle();
|
||||||
|
const stopped = requests;
|
||||||
|
await delay(40);
|
||||||
|
assert.equal(requests, stopped, "polling continued after timeout");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("destroys hung requests on the per-request and overall timeouts", async () => {
|
||||||
|
let requests = 0;
|
||||||
|
await withEndpoint(() => { requests++; }, async (port, idle) => {
|
||||||
|
await assert.rejects(waitForBrowserService(port, short), /not ready within/);
|
||||||
|
assert.ok(requests >= 2, "hung attempt was not bounded before the overall deadline");
|
||||||
|
await idle();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts a hung request on parent EOF and leaves no polling/connection behind", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let started!: () => void;
|
||||||
|
const requestStarted = new Promise<void>((resolve) => { started = resolve; });
|
||||||
|
let requests = 0;
|
||||||
|
await withEndpoint(() => { requests++; started(); }, async (port, idle) => {
|
||||||
|
const waiting = waitForBrowserService(port, { signal: controller.signal });
|
||||||
|
await requestStarted;
|
||||||
|
controller.abort(new Error("parent EOF"));
|
||||||
|
await assert.rejects(waiting, /parent EOF/);
|
||||||
|
await idle();
|
||||||
|
await delay(80);
|
||||||
|
assert.equal(requests, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not start any request if the parent already closed", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort(new Error("parent EOF"));
|
||||||
|
let requests = 0;
|
||||||
|
await withEndpoint(() => { requests++; }, async (port) => {
|
||||||
|
await assert.rejects(waitForBrowserService(port, { signal: controller.signal }), /parent EOF/);
|
||||||
|
await delay(40);
|
||||||
|
assert.equal(requests, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/** No display, real browser, installed app, or production dependency tree needed. */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { formatProbeError, probeEnv } from "../scripts/smoke-demo-runtime.mjs";
|
||||||
|
|
||||||
|
const script = fileURLToPath(new URL("../scripts/smoke-demo-runtime.mjs", import.meta.url));
|
||||||
|
|
||||||
|
function withPayload(test: (fixture: {
|
||||||
|
root: string;
|
||||||
|
write: (relative: string, content: string) => void;
|
||||||
|
run: () => ReturnType<typeof spawnSync>;
|
||||||
|
}) => void) {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-demo-payload-test-"));
|
||||||
|
const write = (relative: string, content: string) => {
|
||||||
|
const file = path.join(root, relative);
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, content);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
write("server.js", "// fixture");
|
||||||
|
write("node_modules/puppeteer-core/package.json", JSON.stringify({ name: "puppeteer-core", type: "module", main: "lib/index.js" }));
|
||||||
|
write("node_modules/puppeteer-core/lib/index.js", 'import "./browser-data/browser-data.js"; export default { connect() {} };');
|
||||||
|
test({ root, write, run: () => spawnSync(process.execPath, [script, "--standalone", root], {
|
||||||
|
encoding: "utf8", timeout: 10_000,
|
||||||
|
}) });
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("demo standalone dependency gate", () => {
|
||||||
|
it("imports a complete payload and does not change it", () => {
|
||||||
|
withPayload(({ root, write, run }) => {
|
||||||
|
write("node_modules/puppeteer-core/lib/browser-data/browser-data.js", "export const fixture = true;");
|
||||||
|
const before = fs.readdirSync(root, { recursive: true });
|
||||||
|
const result = run();
|
||||||
|
assert.equal(result.status, 0, String(result.stderr));
|
||||||
|
assert.match(String(result.stdout), /puppeteer-core import ok/);
|
||||||
|
assert.deepEqual(fs.readdirSync(root, { recursive: true }), before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when a transitive runtime subdirectory is missing", () => {
|
||||||
|
withPayload(({ root, run }) => {
|
||||||
|
const before = fs.readdirSync(root, { recursive: true });
|
||||||
|
const result = run();
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(String(result.stderr), /ERR_MODULE_NOT_FOUND/);
|
||||||
|
assert.match(String(result.stderr), /browser-data/);
|
||||||
|
assert.deepEqual(fs.readdirSync(root, { recursive: true }), before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects imports outside the payload even when they resolve successfully", () => {
|
||||||
|
withPayload(({ write, run }) => {
|
||||||
|
// A sibling file, not just a missing file: resolution succeeds but confinement must fail.
|
||||||
|
const sibling = fs.mkdtempSync(path.join(os.tmpdir(), "lh-demo-outside-"));
|
||||||
|
try {
|
||||||
|
const external = path.join(sibling, "dependency.mjs");
|
||||||
|
fs.writeFileSync(external, "export const outside = true;");
|
||||||
|
write("node_modules/puppeteer-core/lib/index.js", `import ${JSON.stringify(pathToFileURL(external).href)}; export default { connect() {} };`);
|
||||||
|
const result = run();
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(String(result.stderr), /dependency escaped standalone payload/);
|
||||||
|
} finally { fs.rmSync(sibling, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot substitute the checkout's Puppeteer for an absent payload package", () => {
|
||||||
|
withPayload(({ root, run }) => {
|
||||||
|
fs.rmSync(path.join(root, "node_modules"), { recursive: true });
|
||||||
|
const result = run();
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.doesNotMatch(String(result.stdout), /import ok/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves nested fetch/socket causes without dumping headers or environment", () => {
|
||||||
|
const socket = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED", address: "127.0.0.1", port: 54321 });
|
||||||
|
const cause = new AggregateError([socket], "connection failed");
|
||||||
|
const error = Object.assign(new TypeError("fetch failed", { cause }), { headers: { authorization: "private-header" }, env: { TOKEN: "private-token" } });
|
||||||
|
const output = formatProbeError(error);
|
||||||
|
assert.match(output, /fetch failed/);
|
||||||
|
assert.match(output, /connection failed/);
|
||||||
|
assert.match(output, /ECONNREFUSED/);
|
||||||
|
assert.match(output, /127\.0\.0\.1/);
|
||||||
|
assert.match(output, /54321/);
|
||||||
|
assert.doesNotMatch(output, /private-header|private-token|authorization|TOKEN/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips secrets and uses only scratch data/auth/profile locations", () => {
|
||||||
|
const original = { PATH: "/bin", API_KEY: "fake", GH_TOKEN: "fake", MY_SECRET: "fake", PASSWORD: "fake", NODE_OPTIONS: "--require anything", NODE_PATH: "/outside", ELECTRON_RUN_AS_NODE: "1", LH_DEV_URL: "http://localhost", LH_DATA_DIR: "/real", LH_AUTH_FILE: "/real/auth.json", GOOGLE_APPLICATION_CREDENTIALS: "/real/google.json", AWS_SHARED_CREDENTIALS_FILE: "/real/aws", AWS_PROFILE: "real-profile", CLOUDSDK_CONFIG: "/real/gcloud" };
|
||||||
|
const env = probeEnv(original, "/scratch");
|
||||||
|
for (const key of ["API_KEY", "GH_TOKEN", "MY_SECRET", "PASSWORD", "NODE_OPTIONS", "NODE_PATH", "ELECTRON_RUN_AS_NODE", "LH_DEV_URL", "GOOGLE_APPLICATION_CREDENTIALS", "AWS_SHARED_CREDENTIALS_FILE", "AWS_PROFILE", "CLOUDSDK_CONFIG"]) assert.equal(key in env, false, key);
|
||||||
|
assert.equal(env.PATH, "/bin");
|
||||||
|
assert.equal(env.LH_DATA_DIR, path.join("/scratch", "data"));
|
||||||
|
assert.equal(env.LH_AUTH_FILE, path.join("/scratch", "auth.json"));
|
||||||
|
assert.equal(env.LH_IGNORE_ENV_KEYS, "1");
|
||||||
|
assert.equal(env.HOME, "/scratch");
|
||||||
|
assert.equal(original.API_KEY, "fake");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
|
|
||||||
@@ -17,7 +18,8 @@ import {
|
|||||||
PRACTICE_PROBLEM_TOOL,
|
PRACTICE_PROBLEM_TOOL,
|
||||||
type LhMessage,
|
type LhMessage,
|
||||||
} from "../src/core/native-messages.ts";
|
} from "../src/core/native-messages.ts";
|
||||||
import { concludeTool, createPracticeGate, problemTool, practiceTools } from "../src/agent/tools/practice.ts";
|
import { CONCLUDE_ACK_TEXT } from "../src/core/contracts/chat.ts";
|
||||||
|
import { concludeInputObject, concludeTool, createPracticeGate, problemTool, practiceTools } from "../src/agent/tools/practice.ts";
|
||||||
import { composeInstructions, createPracticeAgent } from "../src/agent/teacher-agent.ts";
|
import { composeInstructions, createPracticeAgent } from "../src/agent/teacher-agent.ts";
|
||||||
import { allSegmentPaths } from "../src/agent/prompt-segments.ts";
|
import { allSegmentPaths } from "../src/agent/prompt-segments.ts";
|
||||||
|
|
||||||
@@ -235,6 +237,92 @@ describe("the practice agent", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── persistent nudges (shipped defaults, no model calls) ──────────────────────
|
||||||
|
|
||||||
|
describe("persistent practice nudges", () => {
|
||||||
|
const doctrine = readFileSync(new URL("../src/agent/prompts/practice/10-doctrine.md", import.meta.url), "utf8");
|
||||||
|
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim();
|
||||||
|
const approvedParagraphs = [
|
||||||
|
"- When a nudge does not land, reassess where the learner is stuck and offer only the next necessary piece of information. Do not jump to the full solution. Tentative answers, questions, and thinking aloud remain work in progress.",
|
||||||
|
"**On a wrong answer**, state the verdict plainly and localize the first break without giving the correction. Give the smallest nudge that lets the learner make the next move, then wait. If that nudge does not land, give another minimal nudge based on the new thinking. A failed attempt is never a reason to reveal the correction or conclude the problem. Continue until the learner finds and completes the correct solution.",
|
||||||
|
"Conclude when the learner has solved the problem correctly or explicitly asks to end it. Do not infer permission to end it from mistakes, elapsed time, the number of attempts, frustration, or an apparent inability to proceed.",
|
||||||
|
];
|
||||||
|
|
||||||
|
it("preserves the approved paragraphs in source and the effective default prompt", () => {
|
||||||
|
// Pin approved wording and Markdown, but allow incidental line wrapping.
|
||||||
|
for (const text of [doctrine, composeInstructions("practice")]) {
|
||||||
|
const paragraphs = text.split(/\n\s*\n|\n(?=- )/).map(normalizeWhitespace);
|
||||||
|
for (const approved of approvedParagraphs) {
|
||||||
|
assert.ok(paragraphs.includes(approved), `missing approved paragraph: ${approved}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not compose the retired attempt cutoff, failed-repair reveal, or automatic-abort instructions", () => {
|
||||||
|
for (const text of [doctrine, composeInstructions("practice")]) {
|
||||||
|
const normalized = normalizeWhitespace(text);
|
||||||
|
assert.doesNotMatch(normalized, /\b(?:one|single|1) bounded attempt\b/i);
|
||||||
|
assert.doesNotMatch(normalized, /If the repair attempt fails, reveal and move on/i);
|
||||||
|
assert.doesNotMatch(normalized, /A correction found by the learner after a confident error consolidates more/i);
|
||||||
|
assert.doesNotMatch(normalized, /when the trace shows genuine inability, drop straight to R5 and reveal/i);
|
||||||
|
assert.doesNotMatch(normalized, /when the learner surrenders or cannot proceed/i);
|
||||||
|
assert.doesNotMatch(normalized, /when the marginal yield of staying is exhausted/i);
|
||||||
|
assert.doesNotMatch(normalized, /do not withhold them past the point of exhaustion/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the approved concluded-problem switch and unbounded structural repair in source and the effective default prompt", () => {
|
||||||
|
const approvedDifficulty = "3. **Difficulty** — from the last outcomes on this target: clean success, one notch up; success that needed hints, hold; a miss, one notch down and consider dropping a mode. Two missed concluded problems in a row on this target: switch targets for the next demand. Never switch away from an active problem because of the number of attempts.";
|
||||||
|
const approvedClassification = "Classify every miss: cue miss (nothing came), discrimination error (wrong candidate won), execution slip (right proposition, wrong run), careless, or structural (the retrieved proposition is itself wrong — a misconception). The first four are practice information. Structural misses call for minimal nudges toward repairing the misconception, not an immediate correction or re-teaching. Keep each nudge surgical; do not impose a one-line or one-attempt limit on the repair as a whole.";
|
||||||
|
for (const text of [doctrine, composeInstructions("practice")]) {
|
||||||
|
const normalized = normalizeWhitespace(text);
|
||||||
|
assert.ok(normalized.includes(approvedDifficulty), "preserve the difficulty readout and exact approved switching rule");
|
||||||
|
assert.ok(normalized.includes(approvedClassification), "preserve the miss taxonomy and exact approved structural-repair rule");
|
||||||
|
assert.doesNotMatch(normalized, /two misses in a row, switch targets/i);
|
||||||
|
assert.doesNotMatch(normalized, /Structural gets one surgical line/i);
|
||||||
|
assert.doesNotMatch(normalized, /the correction, stated once/i);
|
||||||
|
assert.doesNotMatch(normalized, /if a repair needs more than a line/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the approved misconception introduction without an affirmative one-line repair cap", () => {
|
||||||
|
const approvedIntroduction = "- **Not a teaching session.** You will find misconceptions. Address them with minimal, surgical nudges, over as many exchanges as needed. A practice session that drifts into re-teaching has stopped practicing.";
|
||||||
|
for (const text of [doctrine, composeInstructions("practice")]) {
|
||||||
|
const normalized = normalizeWhitespace(text);
|
||||||
|
assert.ok(normalized.includes(approvedIntroduction), "preserve the exact approved introduction and the rest of the not-teaching bullet");
|
||||||
|
assert.doesNotMatch(normalized, /Repair them in a line, surgically, and return to practice\./i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the many-claims default, hint ladder, dismissal, and explicit-go-ahead pause", () => {
|
||||||
|
const prompt = normalizeWhitespace(composeInstructions("practice"));
|
||||||
|
assert.match(prompt, /A problem may take many claims; staying is the default/);
|
||||||
|
for (const rung of ["R0", "R1", "R2", "R3", "R4", "R5"]) {
|
||||||
|
assert.ok(prompt.includes(`**${rung} —`));
|
||||||
|
}
|
||||||
|
assert.match(prompt, /Concluding ends your turn; the next problem is posed only when the learner explicitly moves on/);
|
||||||
|
assert.match(prompt, /After the conclude's result.*END YOUR TURN: no further tool calls in that reply, never another problem/);
|
||||||
|
assert.match(prompt, /A clarification question is never the go-ahead; when in doubt, answer and wait/);
|
||||||
|
assert.match(prompt, /The learner may discard a problem without concluding it/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aligns the conclude description with the canonical ack without changing outcomes or the runtime pause mechanism", async () => {
|
||||||
|
const tool = concludeTool(createPracticeGate(ctxOf(activeProblemRecord())));
|
||||||
|
const description = normalizeWhitespace(tool.description);
|
||||||
|
assert.match(description, /records the outcome \+ verdict, reveals the committed solution, and frees the learner's composer/);
|
||||||
|
assert.match(description, /After the result, say your closing words about the learner's work if useful, then END YOUR TURN — no further tool calls/);
|
||||||
|
assert.ok(description.includes(CONCLUDE_ACK_TEXT.slice(CONCLUDE_ACK_TEXT.indexOf("The next problem"))));
|
||||||
|
assert.match(description, /Nothing may conclude unless a problem is active/);
|
||||||
|
assert.doesNotMatch(description, /turn CONTINUES|reveal, then pose the next problem/);
|
||||||
|
assert.deepEqual(concludeInputObject.properties.outcome.anyOf.map((outcome) => outcome.const), [
|
||||||
|
"solved", "solvedWithHints", "movedOn", "surrendered", "dismissed",
|
||||||
|
]);
|
||||||
|
const result = await tool.execute("call_persistent_nudges", CONCLUDE_INPUT as never);
|
||||||
|
assert.equal((result.content[0] as { text: string }).text, CONCLUDE_ACK_TEXT);
|
||||||
|
assert.equal(result.terminate, undefined, "closing prose stays possible; the pause remains an instruction, not a new runtime gate");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── the prompt composition ──────────────────────────────────────────────────
|
// ── the prompt composition ──────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("practice prompt composition (own directory + the formatting appendix)", () => {
|
describe("practice prompt composition (own directory + the formatting appendix)", () => {
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/** Display repair is shared, not a mutation of saved practice/teaching arguments. */
|
||||||
|
import React from "react";
|
||||||
|
(globalThis as Record<string, unknown>).React = React;
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { parseHTML } from "linkedom";
|
||||||
|
import { validateToolArguments } from "@earendil-works/pi-ai";
|
||||||
|
import { problemInputObject } from "../src/agent/tools/practice.ts";
|
||||||
|
import { PracticeDock } from "../components/practice-dock.tsx";
|
||||||
|
import { ProblemCard, ConcludeCard } from "../components/practice-cards.tsx";
|
||||||
|
import { MessageResponse } from "../components/ai-elements/message.tsx";
|
||||||
|
import {
|
||||||
|
repairMathForDisplay, healOverescapedCommands, restoreMathEscapes,
|
||||||
|
parseQuizInput, parseAskInput, parseExplainInput, activeProblemOf, projectView,
|
||||||
|
toLlmMessages, type LhMessage, type ArtifactPartProps,
|
||||||
|
} from "../src/core/index.ts";
|
||||||
|
|
||||||
|
const tex = String.raw`\nabla f(x) \in \mathbb{R}^{m \times n}`;
|
||||||
|
const clean = `$${tex}$`;
|
||||||
|
const doubled = clean.replace(/\\/g, "\\\\");
|
||||||
|
const quadrupled = clean.replace(/\\/g, "\\\\\\\\");
|
||||||
|
const debris = "$\forall x \\in \\mathbb{R}$"; // JSON's form feed consumed the f.
|
||||||
|
const repairedDebris = String.raw`\forall x \in \mathbb{R}`;
|
||||||
|
const matrix = "$$\n" + String.raw`\\begin{pmatrix}1 & 2 \\ 3 & 4\\end{pmatrix}` + "\n$$";
|
||||||
|
const repairedMatrix = String.raw`\begin{pmatrix}1 & 2 \\ 3 & 4\end{pmatrix}`;
|
||||||
|
const part = (input: unknown, over: Partial<ArtifactPartProps["part"]> = {}): ArtifactPartProps["part"] => ({
|
||||||
|
type: "tool-problem", state: "input-available", toolCallId: "call_math", input, ...over,
|
||||||
|
});
|
||||||
|
const dock = (input: { statement: string }, expanded = true) => (
|
||||||
|
<PracticeDock problemCallId="call_math" input={input} expanded={expanded} onExpand={() => {}} onSubmit={() => {}} />
|
||||||
|
);
|
||||||
|
|
||||||
|
function math(html: string) {
|
||||||
|
const { document } = parseHTML(html);
|
||||||
|
return {
|
||||||
|
annotations: [...document.querySelectorAll('annotation[encoding="application/x-tex"]')].map((n) => n.textContent),
|
||||||
|
glyphs: [...document.querySelectorAll(".katex-html")].map((n) => n.textContent).join(""),
|
||||||
|
errors: document.querySelectorAll(".katex-error").length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function assertMath(html: string, expected = [tex], glyphs = ["∇", "∈", "×"]) {
|
||||||
|
const actual = math(html);
|
||||||
|
assert.deepEqual(actual.annotations, expected, "exact TeX, not merely absence of errors");
|
||||||
|
assert.equal(actual.errors, 0);
|
||||||
|
for (const glyph of glyphs) assert.ok(actual.glyphs.includes(glyph), `rendered glyph ${glyph}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("repairMathForDisplay — the existing composition, unchanged", () => {
|
||||||
|
const unchanged = [
|
||||||
|
clean,
|
||||||
|
String.raw`outside \\nabla`,
|
||||||
|
String.raw`$a \\\nabla b$`,
|
||||||
|
String.raw`$a \\\\\nabla b$`,
|
||||||
|
String.raw`$a \\ 2 \\& 3 \\[6pt] 4$`,
|
||||||
|
"$$\na \\\\\\\\ 2\nb\n$$", // four real row-break slashes survive too.
|
||||||
|
"`" + doubled + "`",
|
||||||
|
"```tex\n" + doubled + "\n```",
|
||||||
|
"`" + debris + "`",
|
||||||
|
"```tex\n" + debris + "\n```",
|
||||||
|
String.raw`$\\nabla`,
|
||||||
|
"$$A = \\\\begin{pmatrix}\n1 & 2\n\\\\end{pmatrix}$$", // unsupported midline display close.
|
||||||
|
];
|
||||||
|
for (const [index, value] of [...unchanged, doubled, quadrupled, debris, matrix,
|
||||||
|
String.raw`\(\\nabla\)`, "\\[\n\\\\nabla\n\\]"].entries()) {
|
||||||
|
it(`matches the old composition byte-for-byte (${index})`, () => {
|
||||||
|
assert.equal(repairMathForDisplay(value), healOverescapedCommands(restoreMathEscapes(value)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
it("preserves clean math, odd runs, row breaks, code, prose and existing recognition limits", () => {
|
||||||
|
for (const value of unchanged) assert.equal(repairMathForDisplay(value), value);
|
||||||
|
assert.equal(repairMathForDisplay(doubled), clean);
|
||||||
|
assert.equal(repairMathForDisplay(quadrupled), clean);
|
||||||
|
assert.equal(repairMathForDisplay(debris), `$${repairedDebris}$`);
|
||||||
|
assert.equal(repairMathForDisplay(matrix), `$$\n${repairedMatrix}\n$$`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("practice display surfaces", () => {
|
||||||
|
for (const [name, raw, expected, glyphs] of [
|
||||||
|
["two command slashes", doubled, tex, ["∇", "∈", "×"]],
|
||||||
|
["four command slashes", quadrupled, tex, ["∇", "∈", "×"]],
|
||||||
|
["JSON debris", debris, repairedDebris, ["∀", "∈"]],
|
||||||
|
["clean math", clean, tex, ["∇", "∈", "×"]],
|
||||||
|
["matrix with legitimate row break", matrix, repairedMatrix, ["1", "2", "3", "4"]],
|
||||||
|
] as const) {
|
||||||
|
it(`${name}: expanded dock, settled statement, verdict, committed reveal and legacy fallback`, () => {
|
||||||
|
const input = Object.freeze({ statement: raw, modelSolution: `HIDDEN COMMITMENT ${raw}` });
|
||||||
|
const conclusion = Object.freeze({ outcome: "solved", verdict: raw, solutionReveal: raw });
|
||||||
|
const before = JSON.stringify({ input, conclusion });
|
||||||
|
for (const element of [dock(input), <ProblemCard part={part(input)} />]) {
|
||||||
|
const html = renderToStaticMarkup(element);
|
||||||
|
assertMath(html, [expected], [...glyphs]);
|
||||||
|
assert.ok(!html.includes("HIDDEN COMMITMENT"));
|
||||||
|
}
|
||||||
|
assertMath(renderToStaticMarkup(<ConcludeCard part={part(conclusion, { type: "tool-conclude", reveal: raw })} />),
|
||||||
|
[expected, expected], [...glyphs]);
|
||||||
|
assertMath(renderToStaticMarkup(<ConcludeCard part={part({ outcome: "solved", solutionReveal: raw })} />),
|
||||||
|
[expected], [...glyphs]);
|
||||||
|
assert.equal(JSON.stringify({ input, conclusion }), before);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps a boundary-converted numeric statement raw and preserves prior dock passthrough", () => {
|
||||||
|
const raw = { statement: 17, mode: "recall", modelSolution: "Committed solution." };
|
||||||
|
const call = { type: "toolCall", id: "numeric_problem", name: "problem", arguments: raw };
|
||||||
|
const validated = validateToolArguments(
|
||||||
|
{ name: "problem", parameters: problemInputObject } as never,
|
||||||
|
call as never,
|
||||||
|
) as { statement: unknown };
|
||||||
|
assert.equal(validated.statement, "17", "the actual tool boundary converts its cloned arguments");
|
||||||
|
assert.equal(raw.statement, 17, "saved arguments retain the number");
|
||||||
|
const messages = [
|
||||||
|
{ id: "numeric_pose", role: "assistant", content: [call], timestamp: 1 },
|
||||||
|
{ id: "numeric_ack", role: "toolResult", toolCallId: call.id, toolName: "problem", content: [{ type: "text", text: "Problem posed" }], details: null, isError: false, timestamp: 2 },
|
||||||
|
] as unknown as LhMessage[];
|
||||||
|
const before = structuredClone(messages);
|
||||||
|
const active = activeProblemOf(messages);
|
||||||
|
assert.ok(active);
|
||||||
|
assert.equal((active.input as { statement: unknown }).statement, 17);
|
||||||
|
// Mirror the existing runtime cast at the Chat -> PracticeDock seam.
|
||||||
|
const input = active.input as { statement: string };
|
||||||
|
let expanded = "";
|
||||||
|
assert.doesNotThrow(() => { expanded = renderToStaticMarkup(dock(input)); });
|
||||||
|
const { document } = parseHTML(expanded);
|
||||||
|
assert.equal(document.querySelector(".lh-prose")!.innerHTML,
|
||||||
|
parseHTML(renderToStaticMarkup(<MessageResponse>{input.statement}</MessageResponse>)).document.firstElementChild!.outerHTML,
|
||||||
|
"non-string values pass through exactly as before, without string coercion or a new fallback");
|
||||||
|
const collapsed = renderToStaticMarkup(dock(input, false));
|
||||||
|
assert.match(collapsed, /data-testid="practice-dock-bar"/);
|
||||||
|
assert.equal(parseHTML(collapsed).document.querySelector("span.truncate")!.textContent, "17");
|
||||||
|
assert.deepEqual(math(collapsed).annotations, []);
|
||||||
|
assert.deepEqual(messages, before);
|
||||||
|
assert.equal(raw.statement, 17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves code spans/fences across every repaired display surface", () => {
|
||||||
|
const raw = "`" + doubled + "`\n\n```text\n" + doubled + "\n```";
|
||||||
|
for (const element of [dock({ statement: raw }), <ProblemCard part={part({ statement: raw })} />,
|
||||||
|
<ConcludeCard part={part({ outcome: "solved", verdict: raw }, { reveal: raw })} />]) {
|
||||||
|
const html = renderToStaticMarkup(element);
|
||||||
|
assert.deepEqual(math(html).annotations, []);
|
||||||
|
const { document } = parseHTML(html);
|
||||||
|
assert.ok(document.querySelector("code")?.textContent?.includes(doubled));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not change the collapsed preview, dismissal or global prose renderer", () => {
|
||||||
|
const preview = renderToStaticMarkup(dock({ statement: doubled }, false));
|
||||||
|
assert.ok(preview.includes(doubled));
|
||||||
|
assert.deepEqual(math(preview).annotations, []);
|
||||||
|
const dismissed = renderToStaticMarkup(<ConcludeCard part={part({ outcome: "dismissed", verdict: doubled }, { reveal: doubled })} />);
|
||||||
|
assert.ok(dismissed.includes("Problem dismissed."));
|
||||||
|
assert.deepEqual(math(dismissed).annotations, []);
|
||||||
|
const plain = math(renderToStaticMarkup(<MessageResponse>{doubled}</MessageResponse>));
|
||||||
|
assert.deepEqual(plain.annotations, [doubled.slice(1, -1)]);
|
||||||
|
assert.ok(!plain.glyphs.includes("∇")); // Same unchanged assistant/user MessageResponse path.
|
||||||
|
});
|
||||||
|
|
||||||
|
it("teaching quiz/ask/explain still repair the same bytes without mutating their inputs", () => {
|
||||||
|
const quiz = { question: doubled, options: [{ label: "yes" }, { label: "no" }], correctAnswer: "yes", explanation: debris };
|
||||||
|
const ask = { question: doubled };
|
||||||
|
const explain = { question: doubled, correctAnswer: debris };
|
||||||
|
const before = structuredClone({ quiz, ask, explain });
|
||||||
|
assert.equal(parseQuizInput(quiz)?.question, clean);
|
||||||
|
assert.equal(parseQuizInput(quiz)?.explanation, `$${repairedDebris}$`);
|
||||||
|
assert.equal(parseAskInput(ask)?.question, clean);
|
||||||
|
assert.equal(parseExplainInput(explain)?.question, clean);
|
||||||
|
assert.equal(parseExplainInput(explain)?.correctAnswer, `$${repairedDebris}$`);
|
||||||
|
assertMath(renderToStaticMarkup(<MessageResponse>{parseAskInput(ask)!.question}</MessageResponse>));
|
||||||
|
assert.deepEqual({ quiz, ask, explain }, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("native history, active args and the view join remain raw; commitment appears only at conclusion", () => {
|
||||||
|
const stamps = { api: "faux", provider: "test", model: "test-model", timestamp: 1, stopReason: "toolUse" };
|
||||||
|
const input = { statement: doubled, modelSolution: `COMMITTED ${quadrupled}` };
|
||||||
|
const messages = [
|
||||||
|
{ ...stamps, id: "a1", role: "assistant", content: [{ type: "toolCall", id: "p1", name: "problem", arguments: input }] },
|
||||||
|
{ id: "r1", role: "toolResult", toolCallId: "p1", toolName: "problem", content: [{ type: "text", text: "Problem posed" }], details: null, isError: false, timestamp: 2 },
|
||||||
|
] as unknown as LhMessage[];
|
||||||
|
const concluded = [...messages,
|
||||||
|
{ ...stamps, id: "a2", role: "assistant", content: [{ type: "toolCall", id: "c1", name: "conclude", arguments: { outcome: "solved", verdict: doubled } }] },
|
||||||
|
{ id: "r2", role: "toolResult", toolCallId: "c1", toolName: "conclude", content: [{ type: "text", text: "Problem concluded" }], details: null, isError: false, timestamp: 3 },
|
||||||
|
] as unknown as LhMessage[];
|
||||||
|
const before = structuredClone(concluded);
|
||||||
|
const history = structuredClone(toLlmMessages(concluded));
|
||||||
|
assert.deepEqual(activeProblemOf(messages)?.input, input);
|
||||||
|
const problem = projectView(messages)[0]!.parts.find((p) => p.type === "tool-problem")!;
|
||||||
|
assert.ok(problem.state && problem.toolCallId);
|
||||||
|
assert.deepEqual(problem.input, input);
|
||||||
|
const statementHtml = renderToStaticMarkup(<ProblemCard part={{ ...problem, state: problem.state, toolCallId: problem.toolCallId }} />);
|
||||||
|
assertMath(statementHtml);
|
||||||
|
assert.ok(!statementHtml.includes("COMMITTED"));
|
||||||
|
const conclusion = projectView(concluded).flatMap((m) => m.parts).find((p) => p.type === "tool-conclude")!;
|
||||||
|
assert.ok(conclusion.state && conclusion.toolCallId);
|
||||||
|
assert.equal(conclusion.reveal, input.modelSolution);
|
||||||
|
const revealHtml = renderToStaticMarkup(<ConcludeCard part={{ ...conclusion, state: conclusion.state, toolCallId: conclusion.toolCallId }} />);
|
||||||
|
assert.ok(revealHtml.includes("COMMITTED"));
|
||||||
|
assertMath(revealHtml, [tex, tex]);
|
||||||
|
assert.deepEqual(concluded, before);
|
||||||
|
assert.deepEqual(toLlmMessages(concluded), history);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user