From 8981456dfdf8294af6790550a8c8d34817f9bc53 Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:24:57 +0400 Subject: [PATCH 01/87] chore: add MCP SDK and CLI stream fixtures Pin @modelcontextprotocol/sdk 1.32.1 for the tool bridge. Fixtures are sanitized `-p` stream-json recordings (text, tool, signed-out). --- package-lock.json | 945 +++++++++++++++++++- package.json | 1 + tests/fixtures/claude-code/signed-out.jsonl | 4 + tests/fixtures/claude-code/text.jsonl | 11 + tests/fixtures/claude-code/tool.jsonl | 26 + 5 files changed, 957 insertions(+), 30 deletions(-) create mode 100644 tests/fixtures/claude-code/signed-out.jsonl create mode 100644 tests/fixtures/claude-code/text.jsonl create mode 100644 tests/fixtures/claude-code/tool.jsonl diff --git a/package-lock.json b/package-lock.json index a7d6a2b..eb4d334 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@earendil-works/pi-agent-core": "1.4.0", "@earendil-works/pi-ai": "1.4.0", + "@modelcontextprotocol/sdk": "1.32.1", "@mozilla/readability": "^0.6.0", "@radix-ui/react-use-controllable-state": "^1.2.6", "@resvg/resvg-js": "^2.6.2", @@ -1610,6 +1611,18 @@ } } }, + "node_modules/@hono/node-server": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.5.tgz", + "integrity": "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4 || ^5.0.0-0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2327,6 +2340,68 @@ "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", "license": "BSD-2-Clause" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@mozilla/readability": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz", @@ -6372,6 +6447,44 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -6419,6 +6532,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/anser": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/anser/-/anser-2.3.5.tgz", @@ -6980,6 +7132,59 @@ "dev": true, "license": "MIT" }, + "node_modules/body-parser": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.1.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.3", + "on-finished": "^2.4.1", + "qs": "^6.16.1", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolbase": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz", @@ -7086,6 +7291,15 @@ "node": ">=12.0.0" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/bytestreamjs": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", @@ -7148,7 +7362,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7162,7 +7375,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -7604,6 +7816,46 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -7611,6 +7863,23 @@ "dev": true, "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -7633,7 +7902,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -8398,6 +8666,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -8639,7 +8916,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -8669,6 +8945,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -9006,6 +9288,15 @@ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -9154,7 +9445,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9164,7 +9454,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9174,7 +9463,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -9310,6 +9598,12 @@ "integrity": "sha512-GwBr6yViW3ttx1kb7/Oh+gKQ1/TrhYwxKqVmg5gS+BK+Qe2KrOa/Vh7w3HPBvgGf0LfcDGoY9I6NHKoA5Hozhw==", "license": "MIT" }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", @@ -9752,6 +10046,36 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -9759,6 +10083,106 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz", + "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -9769,7 +10193,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -9826,7 +10249,6 @@ "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", - "dev": true, "funding": [ { "type": "github", @@ -9947,6 +10369,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -10030,6 +10473,15 @@ "node": ">=12.20.0" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/framer-motion": { "version": "13.1.1", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.1.1.tgz", @@ -10053,6 +10505,15 @@ } } }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -10093,7 +10554,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10196,7 +10656,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -10230,7 +10689,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -10432,7 +10890,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10535,7 +10992,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10564,7 +11020,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -10823,6 +11278,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hono": { + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -10967,6 +11431,26 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -11083,7 +11567,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/inline-style-parser": { @@ -11116,6 +11599,24 @@ "node": ">=12" } }, + "node_modules/ip-address": { + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -11455,6 +11956,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -11624,7 +12131,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/jake": { @@ -11655,6 +12161,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.13", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.13.tgz", + "integrity": "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", @@ -11713,6 +12228,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -12286,7 +12807,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -12649,6 +13169,31 @@ "ce-la-react": "^0.3.2" } }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -13564,6 +14109,35 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/next": { "version": "16.3.2", "resolved": "https://registry.npmjs.org/next/-/next-16.3.2.tgz", @@ -13886,11 +14460,19 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13999,11 +14581,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-finished": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz", + "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -14211,6 +14808,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/partial-json": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", @@ -14247,7 +14853,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -14260,6 +14865,16 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pdfjs-dist": { "version": "6.3.289", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz", @@ -14306,6 +14921,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkijs": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.1.tgz", @@ -14562,6 +15186,23 @@ "node": ">=12.0.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/proxy-agent-negotiate": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", @@ -14637,6 +15278,22 @@ "node": ">=16.0.0" } }, + "node_modules/qs": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -14748,6 +15405,50 @@ } } }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -15209,7 +15910,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -15369,6 +16069,22 @@ "points-on-path": "^0.2.1" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -15536,6 +16252,57 @@ "license": "MIT", "optional": true }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/serialize-error": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", @@ -15553,6 +16320,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -15602,6 +16388,12 @@ "node": ">= 0.4" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/sharp": { "version": "0.35.3", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", @@ -15656,7 +16448,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -15669,7 +16460,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -15692,7 +16482,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -15712,7 +16501,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -15729,7 +16517,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -15748,7 +16535,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -15862,6 +16648,15 @@ "node": ">= 6" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -16353,6 +17148,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tokenlens": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/tokenlens/-/tokenlens-1.3.1.tgz", @@ -16511,6 +17315,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typebox": { "version": "1.3.27", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.27.tgz", @@ -16786,6 +17646,15 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unrs-resolver": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", @@ -16957,6 +17826,15 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -17042,7 +17920,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -17191,7 +18068,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -17289,6 +18165,15 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/zustand": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", diff --git a/package.json b/package.json index fdd6c84..de33dcc 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "dependencies": { "@earendil-works/pi-agent-core": "1.4.0", "@earendil-works/pi-ai": "1.4.0", + "@modelcontextprotocol/sdk": "1.32.1", "@mozilla/readability": "^0.6.0", "@radix-ui/react-use-controllable-state": "^1.2.6", "@resvg/resvg-js": "^2.6.2", diff --git a/tests/fixtures/claude-code/signed-out.jsonl b/tests/fixtures/claude-code/signed-out.jsonl new file mode 100644 index 0000000..19c5beb --- /dev/null +++ b/tests/fixtures/claude-code/signed-out.jsonl @@ -0,0 +1,4 @@ +{"type":"system","subtype":"init","cwd":"/tmp/claude-code-empty","session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","model":"claude-haiku-5-5","tools":[],"mcp_servers":[],"apiKeySource":"none","permissionMode":"default","claude_code_version":"2.1.308","uuid":"941c8a75-e5cc-4014-93ec-4b065e619d15"} +{"type":"system","subtype":"status","status":"requesting","session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","uuid":"1cbc259e-ad27-49ae-8a03-1dd0bc7d04ae"} +{"type":"assistant","message":{"diagnostics":null,"id":"a4eeead1-04bb-47fd-9567-bebc78052a3e","container":null,"model":"","role":"assistant","stop_details":null,"stop_reason":"stop_sequence","stop_sequence":"","type":"message","usage":{"output_tokens_details":null,"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":null,"cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":null,"iterations":null,"speed":null,"fallback_credit":null},"content":[{"type":"text","text":"Not logged in · Please run /login"}],"context_management":null},"parent_tool_use_id":null,"session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","uuid":"58c2fe59-d155-4d2d-a0e1-f64e6aa09199","timestamp":"2026-10-24T14:03:24.319Z","error":"authentication_failed","is_api_error_message":true} +{"duration_api_ms":0,"stop_reason":"stop_sequence","session_id":"433f00b9-adec-48b0-8eb7-c5499f88cc6b","total_cost_usd":0,"usage":{"output_tokens_details":{"thinking_tokens":0},"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard","fallback_credit":null},"modelUsage":{},"permission_denials":[],"terminal_reason":"api_error","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"safety_stops":0,"is_error":true,"num_turns":1,"subtype":"success","api_error_status":null,"result":"Not logged in · Please run /login","type":"result","duration_ms":21,"uuid":"7e41c29d-1912-4c14-8955-9f176fef725f","queued_turn_count":0,"result_index":0} diff --git a/tests/fixtures/claude-code/text.jsonl b/tests/fixtures/claude-code/text.jsonl new file mode 100644 index 0000000..fd12b6a --- /dev/null +++ b/tests/fixtures/claude-code/text.jsonl @@ -0,0 +1,11 @@ +{"type":"system","subtype":"init","cwd":"/tmp/claude-code-empty","session_id":"a489e311-9d94-4990-b539-63a765dd0edd","model":"claude-haiku-5-5","tools":[],"mcp_servers":[],"apiKeySource":"none","permissionMode":"default","claude_code_version":"2.1.308","uuid":"c7e564b7-af08-4024-a89e-66736710e2c1"} +{"type":"system","subtype":"status","status":"requesting","session_id":"a489e311-9d94-4990-b539-63a765dd0edd","uuid":"0738b666-b249-4cca-8c36-f92df8326546"} +{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBka5NLhDVNVihRY4Lm","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"output_tokens":5,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"512e3a21-2563-457b-addb-adade96b3d2b","ttft_ms":1224,"thinking_display":"updates","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"} +{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"0b2a75fb-819f-43ef-9fc7-839f1a5c5a01","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello there"}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"85823b9b-d5e0-4973-93f9-8525a8894762","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"} +{"type":"assistant","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBka5NLhDVNVihRY4Lm","type":"message","role":"assistant","content":[{"type":"text","text":"hello there"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"output_tokens":5,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","uuid":"92acba82-60ca-4502-a5a9-bafd75bd7277","timestamp":"2026-10-24T14:02:46.324Z","request_id":"req_011CgMBkZrxiAPxuioCwrRRu"} +{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"1d19a483-51e6-45b6-96a2-bf445a9ab04b","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"} +{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"output_tokens":5,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":607,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"85ded13f-99fc-4ac2-b211-2c71a40bb817","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"} +{"type":"stream_event","event":{"type":"message_stop"},"session_id":"a489e311-9d94-4990-b539-63a765dd0edd","parent_tool_use_id":null,"uuid":"d9085ac3-b00c-4e22-a17e-0fafcf7782a2","api_message_id":"msg_011CgMBka5NLhDVNVihRY4Lm"} +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"},"uuid":"679f75df-0571-4b65-bc3e-c4cee96c7061","session_id":"a489e311-9d94-4990-b539-63a765dd0edd"} +{"duration_api_ms":1236,"stop_reason":"end_turn","session_id":"a489e311-9d94-4990-b539-63a765dd0edd","total_cost_usd":0.0001241,"usage":{"input_tokens":2,"cache_creation_input_tokens":607,"cache_read_input_tokens":0,"output_tokens":5,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":607,"ephemeral_5m_input_tokens":0},"inference_geo":"global","iterations":[{"input_tokens":2,"output_tokens":5,"cache_read_input_tokens":0,"cache_creation_input_tokens":607,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":607},"type":"message"}],"speed":"standard","fallback_credit":null},"modelUsage":{"claude-haiku-5-5":{"inputTokens":2,"outputTokens":5,"cacheReadInputTokens":0,"cacheCreationInputTokens":607,"webSearchRequests":0,"costUSD":0.0001241,"contextWindow":1000000,"maxOutputTokens":128000,"thinkingTokens":0,"canonicalModel":"claude-haiku-5-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"safety_stops":0,"is_error":false,"num_turns":1,"subtype":"success","api_error_status":null,"result":"hello there","ttft_ms":1245,"type":"result","duration_ms":1258,"uuid":"faf44ee1-84e1-40eb-914e-654abb6c2286","ttft_stream_ms":1244,"time_to_request_ms":21,"first_content_frame_ms":1245,"queued_turn_count":0,"result_index":0} diff --git a/tests/fixtures/claude-code/tool.jsonl b/tests/fixtures/claude-code/tool.jsonl new file mode 100644 index 0000000..c1b7863 --- /dev/null +++ b/tests/fixtures/claude-code/tool.jsonl @@ -0,0 +1,26 @@ +{"type":"system","subtype":"init","cwd":"/tmp/claude-code-empty","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","model":"claude-haiku-5-5","tools":["mcp__learnharness__get_weather"],"mcp_servers":[{"name":"learnharness","status":"connected","source":"dynamic"}],"apiKeySource":"none","permissionMode":"default","claude_code_version":"2.1.308","uuid":"9524e7cd-f580-4019-8e63-861e36aa190d"} +{"type":"system","subtype":"status","status":"requesting","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"6a55f520-5449-4845-bfe7-d3d74e4f5dda"} +{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnEgMnaPp7zJQPnYig","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1102,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1102},"output_tokens":32,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"16a84391-efcc-4221-9029-1fbea99edbab","ttft_ms":1170,"thinking_display":"updates","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"} +{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01JBB4cJsPa585FWb1XrfbNX","name":"mcp__learnharness__get_weather","input":{},"caller":{"type":"direct"}}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"460ff54c-2ab3-46a0-b2bf-c0d780d04e58","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"e73df731-5ecc-439c-811a-7b6cc5219a7f","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"city\": \"Paris"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"1ae8e01b-8cfe-4282-a008-a9ef3f2e05fa","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"}"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"b7cf01be-dc67-463e-ad06-19267ea81cc3","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"} +{"type":"assistant","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnEgMnaPp7zJQPnYig","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JBB4cJsPa585FWb1XrfbNX","name":"mcp__learnharness__get_weather","input":{"city":"Paris"},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1102,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1102},"output_tokens":32,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"da987ff6-9b30-4c1e-8f5b-20bda7386663","timestamp":"2026-10-24T14:03:09.115Z","request_id":"req_011CgMBnETxUPY4jwbRZ3rFy","wire_tool_inputs":{"toolu_01JBB4cJsPa585FWb1XrfbNX":{"city":"Paris"}},"tool_use_meta":[{"id":"toolu_01JBB4cJsPa585FWb1XrfbNX","display_name":"Get Weather","server_display_name":"learnharness","tool_name":"get_weather"}]} +{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"f438dbab-5bac-4b68-a237-ef9564abd25c","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JBB4cJsPa585FWb1XrfbNX","type":"tool_result","content":[{"type":"text","text":"Sunny, 21C in Paris"}]}]},"parent_tool_use_id":null,"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"f44480b5-8e02-4958-888e-fbbe3b54dc80","timestamp":"2026-10-24T14:03:09.124Z","tool_use_result":[{"type":"text","text":"Sunny, 21C in Paris"}],"tool_result_meta":[{"id":"toolu_01JBB4cJsPa585FWb1XrfbNX","permission_decision":{"decision":"accept","source":"config","reason_type":"rule"}}]} +{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":1102,"cache_read_input_tokens":0,"output_tokens":59,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":59,"cache_read_input_tokens":0,"cache_creation_input_tokens":1102,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1102},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"7fec4e77-f338-4f24-810c-d6fa959d154f","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"} +{"type":"stream_event","event":{"type":"message_stop"},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"5d3b3311-08d0-4af6-be82-b0d3f472e99d","api_message_id":"msg_011CgMBnEgMnaPp7zJQPnYig"} +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"},"uuid":"f21e5357-4ffc-4346-b9fc-6543e910eab3","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038"} +{"type":"system","subtype":"status","status":"requesting","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"22297606-28e0-4b2f-9208-bc4be5487a80"} +{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnLsfzVJKssny6FQZm","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":100,"cache_read_input_tokens":1102,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"output_tokens":1,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"90d16779-8e65-40d2-a55e-a68a9786a927","ttft_ms":1274,"thinking_display":"updates","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"1f8d1832-7837-444e-a07e-2598927888bb","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"It"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"29baece7-f120-4088-8901-9ee4c16f9ffa","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"'s sunny in Paris"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"7189b574-320a-4730-9cf7-c92c089bdf95","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" right"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"1db451a5-7027-4b25-bb69-2b8b6b94d600","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" now, with a temperature of"}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"7ca48472-b631-4117-a204-9f6fe7854f4a","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" 21°C."}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"86930cda-4e2b-405c-a45f-29ee42d2efca","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"type":"assistant","message":{"model":"claude-haiku-5-5","id":"msg_011CgMBnLsfzVJKssny6FQZm","type":"message","role":"assistant","content":[{"type":"text","text":"It's sunny in Paris right now, with a temperature of 21°C."}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":100,"cache_read_input_tokens":1102,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"output_tokens":1,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","uuid":"69cad926-aa35-4a82-a24c-599cc5422d99","timestamp":"2026-10-24T14:03:10.497Z","request_id":"req_011CgMBnLc4EeExeJ32dkAoH"} +{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"c05c7c31-f90a-4f4b-8a5b-31f2dae60294","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":100,"cache_read_input_tokens":1102,"output_tokens":25,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":25,"cache_read_input_tokens":1102,"cache_creation_input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"6193aeb9-61b1-41e8-959a-9ae646429d11","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"type":"stream_event","event":{"type":"message_stop"},"session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","parent_tool_use_id":null,"uuid":"22fbda30-76c1-4527-9819-cc3d00fb4e68","api_message_id":"msg_011CgMBnLsfzVJKssny6FQZm"} +{"duration_api_ms":2827,"stop_reason":"end_turn","session_id":"0385b444-f8b1-47bd-b1f9-addd4b300038","total_cost_usd":0.00029382000000000003,"usage":{"input_tokens":4,"cache_creation_input_tokens":1202,"cache_read_input_tokens":1102,"output_tokens":84,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":1202,"ephemeral_5m_input_tokens":0},"inference_geo":"global","iterations":[{"input_tokens":2,"output_tokens":25,"cache_read_input_tokens":1102,"cache_creation_input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":100},"type":"message"}],"speed":"standard","fallback_credit":null},"modelUsage":{"claude-haiku-5-5":{"inputTokens":4,"outputTokens":84,"cacheReadInputTokens":1102,"cacheCreationInputTokens":1202,"webSearchRequests":0,"costUSD":0.00029382000000000003,"contextWindow":1000000,"maxOutputTokens":128000,"thinkingTokens":0,"canonicalModel":"claude-haiku-5-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"safety_stops":0,"is_error":false,"num_turns":2,"subtype":"success","api_error_status":null,"result":"It's sunny in Paris right now, with a temperature of 21°C.","ttft_ms":1457,"type":"result","duration_ms":2854,"uuid":"0be743a1-83e1-45ab-96f5-926ffee7a85b","ttft_stream_ms":1191,"time_to_request_ms":21,"first_content_frame_ms":1191,"queued_turn_count":0,"result_index":0} From 1df95fdfdd57c7a74997c48483ee34b67bf8b0e5 Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:24:57 +0400 Subject: [PATCH 02/87] feat: locate CLI binary Search PATH, then standard install dirs, since the packaged app often starts with a minimal PATH. --- src/agent/claude-code/binary.ts | 61 ++++++++++++++++++++++++++++++++ tests/claude-code-binary.test.ts | 54 ++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/agent/claude-code/binary.ts create mode 100644 tests/claude-code-binary.test.ts diff --git a/src/agent/claude-code/binary.ts b/src/agent/claude-code/binary.ts new file mode 100644 index 0000000..44becc3 --- /dev/null +++ b/src/agent/claude-code/binary.ts @@ -0,0 +1,61 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +type BinaryLookup = { + env: Record; + platform: NodeJS.Platform; + homeDir: string; + isExecutable: (path: string) => boolean; +}; + +type SearchSpec = { + paths: path.PlatformPath; + names: string[]; + knownDirs: string[]; +}; + +function getWindowsSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec { + const paths = path.win32; + const appData = env.APPDATA ?? paths.join(homeDir, "AppData", "Roaming"); + return { + paths, + names: ["claude.cmd", "claude.exe"], + knownDirs: [paths.join(homeDir, ".local", "bin"), paths.join(homeDir, ".claude", "local"), env.npm_config_prefix ?? paths.join(appData, "npm")], + }; +} + +function getPosixSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec { + const paths = path.posix; + const npmBin = env.npm_config_prefix ? paths.join(env.npm_config_prefix, "bin") : paths.join(homeDir, ".npm-global", "bin"); + return { + paths, + names: ["claude"], + knownDirs: [paths.join(homeDir, ".local", "bin"), paths.join(homeDir, ".claude", "local"), npmBin, "/opt/homebrew/bin", "/usr/local/bin"], + }; +} + +export function resolveClaudeBinary({ env, platform, homeDir, isExecutable }: BinaryLookup): string | null { + const { paths, names, knownDirs } = platform === "win32" ? getWindowsSearchSpec(env, homeDir) : getPosixSearchSpec(env, homeDir); + const pathDirs = (env.PATH ?? "").split(paths.delimiter).filter(Boolean); + for (const dir of [...pathDirs, ...knownDirs]) { + for (const name of names) { + const candidate = paths.join(dir, name); + if (isExecutable(candidate)) return candidate; + } + } + return null; +} + +function isExecutableFile(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return fs.statSync(filePath).isFile(); + } catch { + return false; + } +} + +export function getClaudeBinary(): string | null { + return resolveClaudeBinary({ env: process.env, platform: process.platform, homeDir: os.homedir(), isExecutable: isExecutableFile }); +} diff --git a/tests/claude-code-binary.test.ts b/tests/claude-code-binary.test.ts new file mode 100644 index 0000000..05adf28 --- /dev/null +++ b/tests/claude-code-binary.test.ts @@ -0,0 +1,54 @@ +/** + * Claude Code binary lookup (src/agent/claude-code/binary.ts). + * + * The packaged desktop app often starts with a minimal PATH, so lookup falls + * back to the standard install locations. A fake isExecutable set stands in + * for the filesystem. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveClaudeBinary } from "../src/agent/claude-code/binary.ts"; + +const HOME = "/home/u"; + +function resolveWith(executables: string[], env: Record = {}, platform: NodeJS.Platform = "linux", homeDir = HOME) { + const found = new Set(executables); + return resolveClaudeBinary({ env, platform, homeDir, isExecutable: (p) => found.has(p) }); +} + +test("prefers PATH hit", () => { + const binary = resolveWith(["/usr/bin/claude", `${HOME}/.local/bin/claude`], { PATH: "/opt/x:/usr/bin" }); + assert.equal(binary, "/usr/bin/claude"); +}); + +test("falls back to ~/.local/bin when PATH misses", () => { + const binary = resolveWith([`${HOME}/.local/bin/claude`, "/usr/local/bin/claude"], { PATH: "/usr/bin" }); + assert.equal(binary, `${HOME}/.local/bin/claude`); +}); + +test("checks ~/.claude/local, npm global bin, /opt/homebrew/bin, /usr/local/bin in that order", () => { + const all = [`${HOME}/.claude/local/claude`, `${HOME}/.npm-global/bin/claude`, "/opt/homebrew/bin/claude", "/usr/local/bin/claude"]; + for (let i = 0; i < all.length; i++) { + assert.equal(resolveWith(all.slice(i), { PATH: "" }), all[i]); + } +}); + +test("npm global bin follows npm_config_prefix when set", () => { + const binary = resolveWith(["/opt/npm/bin/claude", `${HOME}/.npm-global/bin/claude`], { npm_config_prefix: "/opt/npm" }); + assert.equal(binary, "/opt/npm/bin/claude"); +}); + +test("windows looks for claude.cmd and claude.exe", () => { + const winHome = "C:\\Users\\u"; + const env = { PATH: "C:\\tools;C:\\bin", APPDATA: `${winHome}\\AppData\\Roaming` }; + assert.equal(resolveWith(["C:\\bin\\claude.cmd"], env, "win32", winHome), "C:\\bin\\claude.cmd"); + assert.equal(resolveWith(["C:\\bin\\claude.exe"], env, "win32", winHome), "C:\\bin\\claude.exe"); + assert.equal(resolveWith([`${winHome}\\.local\\bin\\claude.exe`], env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`); + assert.equal(resolveWith([`${winHome}\\AppData\\Roaming\\npm\\claude.cmd`], env, "win32", winHome), `${winHome}\\AppData\\Roaming\\npm\\claude.cmd`); + assert.equal(resolveWith(["C:\\bin\\claude"], env, "win32", winHome), null); +}); + +test("returns null when every location misses", () => { + assert.equal(resolveWith([], { PATH: "/usr/bin:/bin" }), null); +}); From 913b29efe596e0edaee7aec60de3779aa9ed03c9 Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:24:57 +0400 Subject: [PATCH 03/87] feat: add subscription CLI provider catalog opus, sonnet and haiku aliases, low..max thinking, zero cost, keyless auth. Streaming is injected. --- src/agent/claude-code/constants.ts | 8 +++++ src/agent/claude-code/provider.ts | 33 +++++++++++++++++ tests/claude-code-provider.test.ts | 57 ++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 src/agent/claude-code/constants.ts create mode 100644 src/agent/claude-code/provider.ts create mode 100644 tests/claude-code-provider.test.ts diff --git a/src/agent/claude-code/constants.ts b/src/agent/claude-code/constants.ts new file mode 100644 index 0000000..18ac8d7 --- /dev/null +++ b/src/agent/claude-code/constants.ts @@ -0,0 +1,8 @@ +export const CLAUDE_CODE_PROVIDER = "claude-code"; + +export const CLAUDE_CODE_API = "claude-code-cli"; + +export enum ClaudeCodeError { + CliNotFound = "claude CLI not found", + NotSignedIn = "not signed in", +} diff --git a/src/agent/claude-code/provider.ts b/src/agent/claude-code/provider.ts new file mode 100644 index 0000000..c190230 --- /dev/null +++ b/src/agent/claude-code/provider.ts @@ -0,0 +1,33 @@ +import { createProvider, type Api, type Model, type Provider, type SimpleStreamOptions, type StreamFunction } from "@earendil-works/pi-ai"; + +import { CLAUDE_CODE_API, CLAUDE_CODE_PROVIDER } from "./constants.ts"; + +const SHARED_MODEL_FIELDS = { + api: CLAUDE_CODE_API, + provider: CLAUDE_CODE_PROVIDER, + baseUrl: "", + input: ["text", "image"], + // Billed to the user's Claude subscription, not per token. + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: true, + // The CLI has no thinking-off switch; effort low is the floor. + thinkingLevelMap: { off: null, minimal: null, low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "max" }, + contextWindow: 1_000_000, + maxTokens: 128_000, +} satisfies Omit, "id" | "name">; + +export const CLAUDE_CODE_MODELS: readonly Model[] = [ + { ...SHARED_MODEL_FIELDS, id: "opus", name: "Claude Opus" }, + { ...SHARED_MODEL_FIELDS, id: "sonnet", name: "Claude Sonnet" }, + { ...SHARED_MODEL_FIELDS, id: "haiku", name: "Claude Haiku" }, +]; + +export function createClaudeCodeProvider(streamSimple: StreamFunction): Provider { + return createProvider({ + id: CLAUDE_CODE_PROVIDER, + name: "Claude Code", + auth: { apiKey: { name: "Claude Code CLI", resolve: async () => ({ auth: {}, source: "claude CLI" }) } }, + models: CLAUDE_CODE_MODELS, + api: { stream: streamSimple, streamSimple }, + }); +} diff --git a/tests/claude-code-provider.test.ts b/tests/claude-code-provider.test.ts new file mode 100644 index 0000000..a8ef24d --- /dev/null +++ b/tests/claude-code-provider.test.ts @@ -0,0 +1,57 @@ +/** + * Claude Code provider catalog (src/agent/claude-code/provider.ts). + * + * Registered on a fresh pi model collection with a stub stream function, so + * only the catalog and auth surface are under test here. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { clampThinkingLevel, createAssistantMessageEventStream, createModels, getSupportedThinkingLevels } from "@earendil-works/pi-ai"; + +import { CLAUDE_CODE_PROVIDER } from "../src/agent/claude-code/constants.ts"; +import { createClaudeCodeProvider } from "../src/agent/claude-code/provider.ts"; + +function createCollection() { + const collection = createModels(); + collection.setProvider(createClaudeCodeProvider(() => createAssistantMessageEventStream())); + return collection; +} + +test("lists exactly the opus, sonnet and haiku aliases", () => { + const models = createCollection().getModels(CLAUDE_CODE_PROVIDER); + assert.deepEqual( + models.map((m) => m.id), + ["opus", "sonnet", "haiku"], + ); + assert.deepEqual( + models.map((m) => m.name), + ["Claude Opus", "Claude Sonnet", "Claude Haiku"], + ); +}); + +test("supports low through max thinking and clamps off to low", () => { + for (const model of createCollection().getModels(CLAUDE_CODE_PROVIDER)) { + assert.deepEqual(getSupportedThinkingLevels(model), ["low", "medium", "high", "xhigh", "max"]); + assert.equal(clampThinkingLevel(model, "off"), "low"); + assert.equal(clampThinkingLevel(model, "minimal"), "low"); + } +}); + +test("accepts text and image input", () => { + for (const model of createCollection().getModels(CLAUDE_CODE_PROVIDER)) { + assert.deepEqual(model.input, ["text", "image"]); + } +}); + +test("every cost field is zero", () => { + for (const model of createCollection().getModels(CLAUDE_CODE_PROVIDER)) { + assert.deepEqual(model.cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); + } +}); + +test("auth resolves as configured without any key", async () => { + const collection = createCollection(); + assert.ok(await collection.checkAuth(CLAUDE_CODE_PROVIDER)); + assert.equal((await collection.getAvailable(CLAUDE_CODE_PROVIDER)).length, 3); +}); From 5c0425901c06213b38e82dc3789a3c4f26716ac8 Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:35:20 +0400 Subject: [PATCH 04/87] feat: replay pi transcript as CLI input --- src/agent/claude-code/transcript.ts | 75 ++++++++++++++ tests/claude-code-transcript.test.ts | 146 +++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 src/agent/claude-code/transcript.ts create mode 100644 tests/claude-code-transcript.test.ts diff --git a/src/agent/claude-code/transcript.ts b/src/agent/claude-code/transcript.ts new file mode 100644 index 0000000..67cba3c --- /dev/null +++ b/src/agent/claude-code/transcript.ts @@ -0,0 +1,75 @@ +import type { ImageContent, Message, TextContent, Tool } from "@earendil-works/pi-ai"; +import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript"; + +export type ClaudeContentBlock = { type: "text"; text: string } | { type: "image"; source: { type: "base64"; media_type: string; data: string } }; + +export type ClaudeInput = { + systemPrompt: string; + tools: Tool[]; + userContent: ClaudeContentBlock[]; +}; + +type ReplayPart = string | ImageContent; + +function toImageBlock(image: ImageContent): ClaudeContentBlock { + return { type: "image", source: { type: "base64", media_type: image.mimeType, data: image.data } }; +} + +function getLabelledParts(label: string, content: string | (TextContent | ImageContent)[]): ReplayPart[] { + if (typeof content === "string") return [`${label}\n${content}`]; + const text = content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("\n"); + const images = content.filter((block) => block.type === "image"); + return [text ? `${label}\n${text}` : label, ...images]; +} + +function getReplayParts(message: Message): ReplayPart[] { + switch (message.role) { + case "system": + return []; + case "user": + return getLabelledParts("[user]", message.content); + case "assistant": + return message.content.flatMap((block) => { + if (block.type === "text") return [`[assistant]\n${block.text}`]; + if (block.type === "toolCall") return [`[tool call ${block.name}] ${JSON.stringify(block.arguments)}`]; + return []; + }); + case "toolResult": + return getLabelledParts(message.isError ? `[tool result ${message.toolName} error]` : `[tool result ${message.toolName}]`, message.content); + } +} + +function toContentBlocks(parts: ReplayPart[]): ClaudeContentBlock[] { + const blocks: ClaudeContentBlock[] = []; + let pendingText: string[] = []; + const flushText = () => { + if (pendingText.length > 0) blocks.push({ type: "text", text: pendingText.join("\n\n") }); + pendingText = []; + }; + for (const part of parts) { + if (typeof part === "string") { + pendingText.push(part); + } else { + flushText(); + blocks.push(toImageBlock(part)); + } + } + flushText(); + return blocks; +} + +function toUnlabelledBlocks(content: string | (TextContent | ImageContent)[]): ClaudeContentBlock[] { + if (typeof content === "string") return [{ type: "text", text: content }]; + return content.map((block) => (block.type === "text" ? { type: "text", text: block.text } : toImageBlock(block))); +} + +export function toClaudeInput(messages: Message[]): ClaudeInput { + const conversation = messages.filter((message) => message.role !== "system"); + const onlyMessage = conversation.length === 1 ? conversation[0] : null; + const userContent = onlyMessage?.role === "user" ? toUnlabelledBlocks(onlyMessage.content) : toContentBlocks(conversation.flatMap(getReplayParts)); + return { systemPrompt: getCurrentSystemPrompt(messages), tools: getCurrentTools(messages), userContent }; +} + +export function toStreamJsonLine(content: ClaudeContentBlock[]): string { + return JSON.stringify({ type: "user", message: { role: "user", content } }); +} diff --git a/tests/claude-code-transcript.test.ts b/tests/claude-code-transcript.test.ts new file mode 100644 index 0000000..0fbec27 --- /dev/null +++ b/tests/claude-code-transcript.test.ts @@ -0,0 +1,146 @@ +/** + * Claude Code transcript replay (src/agent/claude-code/transcript.ts). + * + * The CLI makes one model call per input user message, so earlier turns are + * rendered as labelled text inside the single user message it receives. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { AssistantMessage, Message, SystemMessage, Tool, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai"; + +import { toClaudeInput, toStreamJsonLine } from "../src/agent/claude-code/transcript.ts"; + +const READ_TOOL: Tool = { name: "read", description: "Read a file", parameters: { type: "object", properties: {} } }; +const WRITE_TOOL: Tool = { name: "write", description: "Write a file", parameters: { type: "object", properties: {} } }; + +function system(fields: Partial): SystemMessage { + return { role: "system", content: "", timestamp: 0, ...fields }; +} + +function user(content: UserMessage["content"]): UserMessage { + return { role: "user", content, timestamp: 0 }; +} + +function assistant(content: AssistantMessage["content"]): AssistantMessage { + return { + role: "assistant", + content, + api: "claude-code-cli", + provider: "claude-code", + model: "opus", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: "stop", + timestamp: 0, + }; +} + +function toolResult(toolName: string, content: ToolResultMessage["content"], isError = false): ToolResultMessage { + return { role: "toolResult", toolCallId: `${toolName}-1`, toolName, content, isError, timestamp: 0 }; +} + +test("system prompt joins all system content, then the live sections", () => { + const messages: Message[] = [ + system({ content: "You are a tutor.", sections: { goals: "Teach TCP.", style: "Be brief." } }), + user("hi"), + system({ content: "The learner switched topics.", sections: { goals: "Teach TLS.", style: null } }), + user("next"), + ]; + assert.equal(toClaudeInput(messages).systemPrompt, "You are a tutor.\n\nThe learner switched topics.\n\nTeach TLS."); +}); + +test("tools reflect toolsAdded minus toolsRemoved", () => { + const messages: Message[] = [ + system({ content: "p", toolsAdded: [READ_TOOL, WRITE_TOOL] }), + user("hi"), + system({ toolsRemoved: [{ name: "read" }] }), + user("next"), + ]; + assert.deepEqual( + toClaudeInput(messages).tools.map((t) => t.name), + ["write"], + ); +}); + +test("single user message passes through as text", () => { + const input = toClaudeInput([system({ content: "p" }), user("what is a socket?")]); + assert.deepEqual(input.userContent, [{ type: "text", text: "what is a socket?" }]); +}); + +test("history renders user, assistant text, tool call name+JSON args and tool result text in order before the latest user message", () => { + const messages: Message[] = [ + user([{ type: "text", text: "open notes.md" }]), + assistant([ + { type: "text", text: "Reading it." }, + { type: "toolCall", id: "read-1", name: "read", arguments: { path: "notes.md" } }, + ]), + toolResult("read", [{ type: "text", text: "# Notes" }]), + assistant([{ type: "text", text: "It has a heading." }]), + user("summarise it"), + ]; + const expected = [ + "[user]\nopen notes.md", + "[assistant]\nReading it.", + '[tool call read] {"path":"notes.md"}', + "[tool result read]\n# Notes", + "[assistant]\nIt has a heading.", + "[user]\nsummarise it", + ].join("\n\n"); + assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]); +}); + +test("error tool results are marked as errors in the replay", () => { + const messages: Message[] = [ + user("open missing.md"), + assistant([{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "missing.md" } }]), + toolResult("read", [{ type: "text", text: "ENOENT" }], true), + user("why?"), + ]; + const expected = ["[user]\nopen missing.md", '[tool call read] {"path":"missing.md"}', "[tool result read error]\nENOENT", "[user]\nwhy?"].join("\n\n"); + assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]); +}); + +test("images in user and tool-result content become base64 image blocks", () => { + const messages: Message[] = [ + user([ + { type: "text", text: "look" }, + { type: "image", data: "AAAA", mimeType: "image/png" }, + ]), + assistant([{ type: "toolCall", id: "shot-1", name: "screenshot", arguments: {} }]), + toolResult("screenshot", [{ type: "image", data: "BBBB", mimeType: "image/jpeg" }]), + user("compare"), + ]; + assert.deepEqual(toClaudeInput(messages).userContent, [ + { type: "text", text: "[user]\nlook" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "AAAA" } }, + { type: "text", text: "[tool call screenshot] {}\n\n[tool result screenshot]" }, + { type: "image", source: { type: "base64", media_type: "image/jpeg", data: "BBBB" } }, + { type: "text", text: "[user]\ncompare" }, + ]); +}); + +test("transcript ending on a tool result renders as history with no trailing user turn", () => { + const messages: Message[] = [ + user("open notes.md"), + assistant([{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "notes.md" } }]), + toolResult("read", [{ type: "text", text: "# Notes" }]), + ]; + const expected = ["[user]\nopen notes.md", '[tool call read] {"path":"notes.md"}', "[tool result read]\n# Notes"].join("\n\n"); + assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]); +}); + +test("thinking blocks are omitted from replay", () => { + const messages: Message[] = [ + user("hi"), + assistant([ + { type: "thinking", thinking: "secret plan" }, + { type: "text", text: "Hello." }, + ]), + user("bye"), + ]; + assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: "[user]\nhi\n\n[assistant]\nHello.\n\n[user]\nbye" }]); +}); + +test('toStreamJsonLine emits {"type":"user","message":{"role":"user","content":[...]}}', () => { + assert.deepEqual(JSON.parse(toStreamJsonLine([{ type: "text", text: "hi" }])), { type: "user", message: { role: "user", content: [{ type: "text", text: "hi" }] } }); +}); From 76b91dc1e5cff6ba8de9e480b84d8c83c298a2a9 Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:06:50 +0400 Subject: [PATCH 05/87] fix: prefer claude.exe over claude.cmd on Windows Node cannot spawn a .cmd file without a shell, so a claude.cmd found earlier on PATH would hide a working claude.exe. --- src/agent/claude-code/binary.ts | 6 +++--- tests/claude-code-binary.test.ts | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/agent/claude-code/binary.ts b/src/agent/claude-code/binary.ts index 44becc3..5e57cda 100644 --- a/src/agent/claude-code/binary.ts +++ b/src/agent/claude-code/binary.ts @@ -20,7 +20,7 @@ function getWindowsSearchSpec(env: BinaryLookup["env"], homeDir: string): Search const appData = env.APPDATA ?? paths.join(homeDir, "AppData", "Roaming"); return { paths, - names: ["claude.cmd", "claude.exe"], + names: ["claude.exe", "claude.cmd"], knownDirs: [paths.join(homeDir, ".local", "bin"), paths.join(homeDir, ".claude", "local"), env.npm_config_prefix ?? paths.join(appData, "npm")], }; } @@ -38,8 +38,8 @@ function getPosixSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSp export function resolveClaudeBinary({ env, platform, homeDir, isExecutable }: BinaryLookup): string | null { const { paths, names, knownDirs } = platform === "win32" ? getWindowsSearchSpec(env, homeDir) : getPosixSearchSpec(env, homeDir); const pathDirs = (env.PATH ?? "").split(paths.delimiter).filter(Boolean); - for (const dir of [...pathDirs, ...knownDirs]) { - for (const name of names) { + for (const name of names) { + for (const dir of [...pathDirs, ...knownDirs]) { const candidate = paths.join(dir, name); if (isExecutable(candidate)) return candidate; } diff --git a/tests/claude-code-binary.test.ts b/tests/claude-code-binary.test.ts index 05adf28..00c7d34 100644 --- a/tests/claude-code-binary.test.ts +++ b/tests/claude-code-binary.test.ts @@ -49,6 +49,13 @@ test("windows looks for claude.cmd and claude.exe", () => { assert.equal(resolveWith(["C:\\bin\\claude"], env, "win32", winHome), null); }); +test("windows prefers claude.exe anywhere over claude.cmd, which Node cannot spawn without a shell", () => { + const winHome = "C:\\Users\\u"; + const env = { PATH: "C:\\bin", APPDATA: `${winHome}\\AppData\\Roaming` }; + const executables = ["C:\\bin\\claude.cmd", `${winHome}\\.local\\bin\\claude.exe`]; + assert.equal(resolveWith(executables, env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`); +}); + test("returns null when every location misses", () => { assert.equal(resolveWith([], { PATH: "/usr/bin:/bin" }), null); }); From 6650f91eca53dc1b31a48009b77345d3adcde0db Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:04:22 +0400 Subject: [PATCH 06/87] feat: add MCP bridge exposing pi tools to the CLI A localhost-only MCP HTTP server, guarded by a per-turn bearer token, lists pi's tools and holds each call until pi has run it and returned the result. --- src/agent/claude-code/tool-bridge.ts | 193 ++++++++++++++++++++++ tests/claude-code-tool-bridge.test.ts | 225 ++++++++++++++++++++++++++ 2 files changed, 418 insertions(+) create mode 100644 src/agent/claude-code/tool-bridge.ts create mode 100644 tests/claude-code-tool-bridge.test.ts diff --git a/src/agent/claude-code/tool-bridge.ts b/src/agent/claude-code/tool-bridge.ts new file mode 100644 index 0000000..92e2aad --- /dev/null +++ b/src/agent/claude-code/tool-bridge.ts @@ -0,0 +1,193 @@ +import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; +import * as http from "node:http"; +import type { AddressInfo } from "node:net"; + +import type { ImageContent, JsonObject, TextContent, Tool, ToolResultMessage } from "@earendil-works/pi-ai"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + type CallToolResult, + type Tool as McpTool, +} from "@modelcontextprotocol/sdk/types.js"; + +export const BRIDGE_SERVER_NAME = "learnharness"; + +const MCP_TOOL_PREFIX = `mcp__${BRIDGE_SERVER_NAME}__`; +const LOOPBACK_HOST = "127.0.0.1"; +const TOKEN_BYTES = 32; + +export type PendingToolCall = { name: string; arguments: JsonObject }; + +export type ToolBridge = { + mcpConfig: string; + allowedTools: string[]; + waitForToolCalls(count: number): Promise; + resolveToolCall(call: PendingToolCall, result: ToolResultMessage): void; + getPendingToolCalls(): PendingToolCall[]; + close(): Promise; +}; + +type ToolCallWaiter = { + count: number; + resolve: (calls: PendingToolCall[]) => void; + reject: (error: Error) => void; +}; + +export function toolNameFromMcp(name: string): string { + return name.startsWith(MCP_TOOL_PREFIX) ? name.slice(MCP_TOOL_PREFIX.length) : name; +} + +function toMcpContent(block: TextContent | ImageContent): CallToolResult["content"][number] { + switch (block.type) { + case "text": + return { type: "text", text: block.text }; + + case "image": + return { type: "image", data: block.data, mimeType: block.mimeType }; + } +} + +function toCallToolResult(result: ToolResultMessage): CallToolResult { + return { content: result.content.map(toMcpContent), isError: result.isError }; +} + +function toMcpTool(tool: Tool): McpTool { + return { name: tool.name, description: tool.description, inputSchema: tool.parameters as McpTool["inputSchema"] }; +} + +class ToolCallQueue { + private readonly results = new Map void>(); + private waiters: ToolCallWaiter[] = []; + + add(call: PendingToolCall): Promise { + const result = new Promise((resolve) => this.results.set(call, resolve)); + + this.wakeWaiters(); + + return result; + } + + waitFor(count: number): Promise { + return new Promise((resolve, reject) => { + this.waiters.push({ count, resolve, reject }); + this.wakeWaiters(); + }); + } + + resolve(call: PendingToolCall, result: ToolResultMessage): void { + this.results.get(call)?.(toCallToolResult(result)); + this.results.delete(call); + } + + list(): PendingToolCall[] { + return [...this.results.keys()]; + } + + clear(): void { + this.results.clear(); + + for (const waiter of this.waiters) waiter.reject(new Error("claude-code tool bridge closed")); + this.waiters = []; + } + + private wakeWaiters(): void { + const calls = this.list(); + + const ready = this.waiters.filter((waiter) => calls.length >= waiter.count); + this.waiters = this.waiters.filter((waiter) => calls.length < waiter.count); + + for (const waiter of ready) waiter.resolve(calls); + } +} + +// Low-level Server on purpose: McpServer.registerTool only takes zod schemas, and pi tools carry +// JSON Schema that must reach the CLI unchanged. +function createMcpServer(tools: Tool[], queue: ToolCallQueue): Server { + const server = new Server({ name: BRIDGE_SERVER_NAME, version: "1.0.0" }, { capabilities: { tools: {} } }); + + server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: tools.map(toMcpTool) })); + + server.setRequestHandler(CallToolRequestSchema, (request) => { + const call = { name: request.params.name, arguments: (request.params.arguments ?? {}) as JsonObject }; + return queue.add(call); + }); + + return server; +} + +function isAuthorized(header: string | undefined, expected: Buffer): boolean { + if (header === undefined) return false; + + const actual = Buffer.from(header); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +function createHttpServer(transport: StreamableHTTPServerTransport, authorization: string): http.Server { + const expected = Buffer.from(authorization); + + return http.createServer((req, res) => { + if (!isAuthorized(req.headers.authorization, expected)) { + res.writeHead(401).end(); + return; + } + + transport.handleRequest(req, res).catch((error: unknown) => { + console.error("claude-code tool bridge request failed", error); + + if (!res.headersSent) res.writeHead(500); + res.end(); + }); + }); +} + +function listen(server: http.Server): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + + server.listen(0, LOOPBACK_HOST, () => resolve(server.address() as AddressInfo)); + }); +} + +function closeHttpServer(server: http.Server): Promise { + server.closeAllConnections(); + + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +function toMcpConfig(port: number, authorization: string): string { + const server = { type: "http", url: `http://${LOOPBACK_HOST}:${port}/mcp`, headers: { Authorization: authorization } }; + + return JSON.stringify({ mcpServers: { [BRIDGE_SERVER_NAME]: server } }); +} + +export async function createToolBridge(tools: Tool[]): Promise { + const authorization = `Bearer ${randomBytes(TOKEN_BYTES).toString("hex")}`; + + const queue = new ToolCallQueue(); + const mcpServer = createMcpServer(tools, queue); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); + await mcpServer.connect(transport); + + const httpServer = createHttpServer(transport, authorization); + const { port } = await listen(httpServer); + + return { + mcpConfig: toMcpConfig(port, authorization), + allowedTools: tools.map((tool) => `${MCP_TOOL_PREFIX}${tool.name}`), + waitForToolCalls: (count) => queue.waitFor(count), + resolveToolCall: (call, result) => queue.resolve(call, result), + getPendingToolCalls: () => queue.list(), + close: async () => { + queue.clear(); + await mcpServer.close(); + await closeHttpServer(httpServer); + }, + }; +} diff --git a/tests/claude-code-tool-bridge.test.ts b/tests/claude-code-tool-bridge.test.ts new file mode 100644 index 0000000..3d18881 --- /dev/null +++ b/tests/claude-code-tool-bridge.test.ts @@ -0,0 +1,225 @@ +/** + * Claude Code MCP tool bridge (src/agent/claude-code/tool-bridge.ts). + * + * A real MCP SDK client talks to the bridge over loopback HTTP, the same way + * the claude CLI does, so listing, auth, pending calls and their results + * are all exercised end to end. + */ +import assert from "node:assert/strict"; +import * as net from "node:net"; +import * as os from "node:os"; +import test from "node:test"; + +import type { Tool, ToolResultMessage } from "@earendil-works/pi-ai"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { Type } from "typebox"; + +import { BRIDGE_SERVER_NAME, createToolBridge, toolNameFromMcp, type ToolBridge } from "../src/agent/claude-code/tool-bridge.ts"; + +const READ_TOOL: Tool = { name: "read", description: "Read a file", parameters: Type.Object({ path: Type.String() }) }; +const SCREENSHOT_TOOL: Tool = { name: "screenshot", description: "Capture the screen", parameters: Type.Object({}) }; + +type BridgeServerConfig = { type: string; url: string; headers: { Authorization: string } }; + +function getServerConfig(bridge: ToolBridge): BridgeServerConfig { + const config = JSON.parse(bridge.mcpConfig) as { mcpServers: Record }; + const server = config.mcpServers[BRIDGE_SERVER_NAME]; + assert.ok(server); + return server; +} + +async function connectClient(bridge: ToolBridge, authorization = getServerConfig(bridge).headers.Authorization): Promise { + const transport = new StreamableHTTPClientTransport(new URL(getServerConfig(bridge).url), { + requestInit: { headers: { Authorization: authorization } }, + }); + const client = new Client({ name: "test", version: "0" }); + await client.connect(transport); + + return client; +} + +function toolResult(content: ToolResultMessage["content"], isError = false): ToolResultMessage { + return { role: "toolResult", toolCallId: "call-1", toolName: "read", content, isError, timestamp: 0 }; +} + +test("lists exactly the given tools with their JSON schemas", async (t) => { + const bridge = await createToolBridge([READ_TOOL, SCREENSHOT_TOOL]); + t.after(() => bridge.close()); + const client = await connectClient(bridge); + t.after(() => client.close()); + + const { tools } = await client.listTools(); + + assert.deepEqual( + tools.map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })), + [ + { name: "read", description: "Read a file", inputSchema: JSON.parse(JSON.stringify(READ_TOOL.parameters)) }, + { name: "screenshot", description: "Capture the screen", inputSchema: JSON.parse(JSON.stringify(SCREENSHOT_TOOL.parameters)) }, + ], + ); + + assert.deepEqual(bridge.allowedTools, ["mcp__learnharness__read", "mcp__learnharness__screenshot"]); +}); + +test("rejects requests without the bearer token", async (t) => { + const bridge = await createToolBridge([READ_TOOL]); + t.after(() => bridge.close()); + + await assert.rejects(connectClient(bridge, "Bearer wrong"), (error: { code?: number }) => error.code === 401); + const response = await fetch(getServerConfig(bridge).url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + assert.equal(response.status, 401); +}); + +test("binds 127.0.0.1", async (t) => { + const bridge = await createToolBridge([READ_TOOL]); + t.after(() => bridge.close()); + + const url = new URL(getServerConfig(bridge).url); + assert.equal(url.hostname, "127.0.0.1"); + + const externalAddress = Object.values(os.networkInterfaces()) + .flat() + .find((address) => address && !address.internal && address.family === "IPv4"); + + if (!externalAddress) { + t.diagnostic("no non-loopback IPv4 interface; skipped external connect check"); + return; + } + + await assert.rejects( + new Promise((resolve, reject) => { + const socket = net.connect(Number(url.port), externalAddress.address, () => resolve(socket.end())); + socket.once("error", reject); + }), + { code: "ECONNREFUSED" }, + ); +}); + +test("tools/call stays pending until resolved and returns its text and image content", async (t) => { + const bridge = await createToolBridge([READ_TOOL]); + t.after(() => bridge.close()); + const client = await connectClient(bridge); + t.after(() => client.close()); + + let settled = false; + const pending = client.callTool({ name: "read", arguments: { path: "notes.md" } }).finally(() => { + settled = true; + }); + + const [call] = await bridge.waitForToolCalls(1); + assert.ok(call); + assert.deepEqual(call, { name: "read", arguments: { path: "notes.md" } }); + assert.deepEqual(bridge.getPendingToolCalls(), [call]); + + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(settled, false); + + bridge.resolveToolCall( + call, + toolResult([ + { type: "text", text: "# Notes" }, + { type: "image", data: "AAAA", mimeType: "image/png" }, + ]), + ); + + const result = await pending; + assert.deepEqual(result.content, [ + { type: "text", text: "# Notes" }, + { type: "image", data: "AAAA", mimeType: "image/png" }, + ]); + assert.notEqual(result.isError, true); + assert.deepEqual(bridge.getPendingToolCalls(), []); +}); + +test("waitForToolCalls resolves once that many calls are pending, in arrival order", async (t) => { + const bridge = await createToolBridge([READ_TOOL, SCREENSHOT_TOOL]); + t.after(() => bridge.close()); + const client = await connectClient(bridge); + t.after(() => client.close()); + + let waited = false; + const waiting = bridge.waitForToolCalls(2).finally(() => { + waited = true; + }); + + void client.callTool({ name: "read", arguments: { path: "a.md" } }).catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(waited, false); + + void client.callTool({ name: "screenshot", arguments: {} }).catch(() => {}); + + assert.deepEqual(await waiting, [ + { name: "read", arguments: { path: "a.md" } }, + { name: "screenshot", arguments: {} }, + ]); +}); + +test("isError result becomes MCP isError true", async (t) => { + const bridge = await createToolBridge([READ_TOOL]); + t.after(() => bridge.close()); + const client = await connectClient(bridge); + t.after(() => client.close()); + + const pending = client.callTool({ name: "read", arguments: { path: "missing.md" } }); + const [call] = await bridge.waitForToolCalls(1); + assert.ok(call); + + bridge.resolveToolCall(call, toolResult([{ type: "text", text: "ENOENT" }], true)); + + const result = await pending; + assert.equal(result.isError, true); + assert.deepEqual(result.content, [{ type: "text", text: "ENOENT" }]); +}); + +test("close fails pending calls and frees the port", async () => { + const bridge = await createToolBridge([READ_TOOL]); + const port = Number(new URL(getServerConfig(bridge).url).port); + const client = await connectClient(bridge); + const pending = client.callTool({ name: "read", arguments: { path: "notes.md" } }); + await bridge.waitForToolCalls(1); + + await bridge.close(); + + await assert.rejects(pending); + assert.deepEqual(bridge.getPendingToolCalls(), []); + await client.close(); + + const probe = net.createServer(); + await new Promise((resolve, reject) => { + probe.once("error", reject); + probe.listen(port, "127.0.0.1", resolve); + }); + await new Promise((resolve) => probe.close(resolve)); +}); + +test("close rejects consumers still waiting for tool calls", { timeout: 1000 }, async () => { + const bridge = await createToolBridge([READ_TOOL]); + const waiting = bridge.waitForToolCalls(1); + + await bridge.close(); + await assert.rejects(waiting, /tool bridge closed/); +}); + +test('mcpConfig is {"mcpServers":{"learnharness":{"type":"http","url":...,"headers":{"Authorization":"Bearer ..."}}}}', async (t) => { + const bridge = await createToolBridge([READ_TOOL]); + t.after(() => bridge.close()); + + const config = JSON.parse(bridge.mcpConfig) as { mcpServers: Record }; + assert.deepEqual(Object.keys(config.mcpServers), ["learnharness"]); + + const server = getServerConfig(bridge); + assert.deepEqual(Object.keys(server).sort(), ["headers", "type", "url"]); + assert.equal(server.type, "http"); + assert.match(server.url, /^http:\/\/127\.0\.0\.1:\d+\/mcp$/); + assert.match(server.headers.Authorization, /^Bearer [0-9a-f]{64}$/); +}); + +test("toolNameFromMcp strips the bridge prefix", () => { + assert.equal(toolNameFromMcp("mcp__learnharness__read"), "read"); + assert.equal(toolNameFromMcp("read"), "read"); +}); From c41c5f3132284b7e1fee889245aa239449206d9a Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:04:22 +0400 Subject: [PATCH 07/87] feat: stream turns through the claude CLI Each turn runs claude -p with stream-json in and out and maps its events onto pi's assistant message stream. A process that stops on tool calls is kept alive and continued when pi sends the results back, instead of replaying the whole transcript. API credentials are removed from the CLI environment so turns always bill to the signed-in subscription. --- src/agent/claude-code/cli-events.ts | 181 +++++ src/agent/claude-code/live-processes.ts | 41 ++ src/agent/claude-code/stream.ts | 602 ++++++++++++++++ tests/claude-code-stream.test.ts | 781 +++++++++++++++++++++ tests/fixtures/claude-code/fake-claude.mjs | 79 +++ 5 files changed, 1684 insertions(+) create mode 100644 src/agent/claude-code/cli-events.ts create mode 100644 src/agent/claude-code/live-processes.ts create mode 100644 src/agent/claude-code/stream.ts create mode 100644 tests/claude-code-stream.test.ts create mode 100644 tests/fixtures/claude-code/fake-claude.mjs diff --git a/src/agent/claude-code/cli-events.ts b/src/agent/claude-code/cli-events.ts new file mode 100644 index 0000000..f532946 --- /dev/null +++ b/src/agent/claude-code/cli-events.ts @@ -0,0 +1,181 @@ +import type { Usage } from "@earendil-works/pi-ai"; + +export type CliUsage = Partial>; + +export type CliBlock = { type: "text" } | { type: "thinking" } | { type: "toolUse"; id: string; name: string }; + +export type CliEvent = + | { type: "messageStart"; usage: CliUsage } + | { type: "blockStart"; index: number; block: CliBlock } + | { type: "textDelta"; index: number; text: string } + | { type: "thinkingDelta"; index: number; thinking: string } + | { type: "toolInputDelta"; index: number; partialJson: string } + | { type: "blockStop"; index: number } + | { type: "messageDelta"; stopReason: string | null; usage: CliUsage } + | { type: "messageStop" } + | { type: "assistantError"; error: string; text: string } + | { type: "resultError"; text: string }; + +type RawUsage = { + input_tokens?: number | null; + output_tokens?: number | null; + cache_read_input_tokens?: number | null; + cache_creation_input_tokens?: number | null; +}; + +type RawContentBlock = { type: string; id?: string; name?: string }; + +type RawDelta = { + type?: string; + text?: string; + thinking?: string; + partial_json?: string; + stop_reason?: string | null; +}; + +type RawStreamEvent = { + type: string; + index?: number; + message?: { usage?: RawUsage }; + content_block?: RawContentBlock; + delta?: RawDelta; + usage?: RawUsage; +}; + +type RawCliLine = { + type?: string; + event?: RawStreamEvent; + error?: string | null; + message?: { content?: { type: string; text?: string }[] }; + is_error?: boolean; + result?: string; + subtype?: string; + errors?: string[]; +}; + +function toUsage(raw: RawUsage): CliUsage { + const usage: CliUsage = {}; + + if (typeof raw.input_tokens === "number") usage.input = raw.input_tokens; + if (typeof raw.output_tokens === "number") usage.output = raw.output_tokens; + if (typeof raw.cache_read_input_tokens === "number") usage.cacheRead = raw.cache_read_input_tokens; + if (typeof raw.cache_creation_input_tokens === "number") usage.cacheWrite = raw.cache_creation_input_tokens; + + return usage; +} + +function toBlock(raw: RawContentBlock): CliBlock | null { + switch (raw.type) { + case "text": + return { type: "text" }; + + case "thinking": + return { type: "thinking" }; + + case "tool_use": + if (typeof raw.id !== "string" || typeof raw.name !== "string") return null; + return { type: "toolUse", id: raw.id, name: raw.name }; + + default: + return null; + } +} + +function parseDelta(index: number, delta: RawDelta): CliEvent | null { + switch (delta.type) { + case "text_delta": + if (typeof delta.text !== "string") return null; + return { type: "textDelta", index, text: delta.text }; + + case "thinking_delta": + if (typeof delta.thinking !== "string") return null; + return { type: "thinkingDelta", index, thinking: delta.thinking }; + + case "input_json_delta": + if (typeof delta.partial_json !== "string") return null; + return { type: "toolInputDelta", index, partialJson: delta.partial_json }; + + default: + return null; + } +} + +function parseBlockEvent(index: number, event: RawStreamEvent): CliEvent | null { + switch (event.type) { + case "content_block_start": { + if (!event.content_block) return null; + + const block = toBlock(event.content_block); + return block ? { type: "blockStart", index, block } : null; + } + + case "content_block_delta": + return event.delta ? parseDelta(index, event.delta) : null; + + case "content_block_stop": + return { type: "blockStop", index }; + + default: + return null; + } +} + +function parseStreamEvent(event: RawStreamEvent): CliEvent | null { + switch (event.type) { + case "message_start": + return { type: "messageStart", usage: toUsage(event.message?.usage ?? {}) }; + + case "message_delta": { + const stopReason = event.delta?.stop_reason ?? null; + return { type: "messageDelta", stopReason, usage: toUsage(event.usage ?? {}) }; + } + + case "message_stop": + return { type: "messageStop" }; + + default: + return event.index === undefined ? null : parseBlockEvent(event.index, event); + } +} + +function getText(line: RawCliLine): string { + const blocks = line.message?.content ?? []; + const texts = blocks.flatMap((block) => (block.type === "text" && block.text ? [block.text] : [])); + + return texts.join("\n"); +} + +function getResultErrorText(line: RawCliLine): string { + if (line.result) return line.result; + if (line.errors?.length) return line.errors.join("\n"); + return `claude CLI failed: ${line.subtype ?? "unknown error"}`; +} + +export function parseCliLine(text: string): CliEvent | null { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + + if (typeof parsed !== "object" || parsed === null) return null; + + const line = parsed as RawCliLine; + + switch (line.type) { + case "stream_event": + return line.event ? parseStreamEvent(line.event) : null; + + case "assistant": + if (typeof line.error !== "string") return null; + return { type: "assistantError", error: line.error, text: getText(line) }; + + case "result": + if (line.is_error !== true) return null; + return { type: "resultError", text: getResultErrorText(line) }; + + default: + return null; + } +} diff --git a/src/agent/claude-code/live-processes.ts b/src/agent/claude-code/live-processes.ts new file mode 100644 index 0000000..f5e24b3 --- /dev/null +++ b/src/agent/claude-code/live-processes.ts @@ -0,0 +1,41 @@ +export type LiveProcess = { toolCallIds: string[]; kill(): void }; + +// pi runs a message's tools (each capped at 30s by tool-execute) and calls back well within this; +// it stays under MCP_TOOL_TIMEOUT so an orphaned process is killed before the CLI gives up on its own. +export const LIVE_PROCESS_IDLE_TIMEOUT_MS = 10 * 60_000; + +/** Test seam (runner.ts's pdfModelOverride idiom). */ +export const liveProcessIdleTimeoutMs = { current: LIVE_PROCESS_IDLE_TIMEOUT_MS }; + +// Each live process mapped to the timer that kills it if pi never calls back. +const liveProcesses = new Map(); + +export function addLiveProcess(process: LiveProcess): void { + const timer = setTimeout(() => { + removeLiveProcess(process); + process.kill(); + }, liveProcessIdleTimeoutMs.current); + + timer.unref(); + + liveProcesses.set(process, timer); +} + +function hasSameIds(ids: string[], otherIds: string[]): boolean { + return ids.length === otherIds.length && ids.every((id) => otherIds.includes(id)); +} + +export function getLiveProcess(toolCallIds: string[]): LiveProcess | null { + if (toolCallIds.length === 0) return null; + + for (const process of liveProcesses.keys()) { + if (hasSameIds(process.toolCallIds, toolCallIds)) return process; + } + + return null; +} + +export function removeLiveProcess(process: LiveProcess): void { + clearTimeout(liveProcesses.get(process)); + liveProcesses.delete(process); +} diff --git a/src/agent/claude-code/stream.ts b/src/agent/claude-code/stream.ts new file mode 100644 index 0000000..919561a --- /dev/null +++ b/src/agent/claude-code/stream.ts @@ -0,0 +1,602 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import { isDeepStrictEqual } from "node:util"; + +import { + createAssistantMessageEventStream, + type Api, + type AssistantMessage, + type AssistantMessageEventStream, + type Message, + type Model, + type SimpleStreamOptions, + type StopReason, + type StreamFunction, + type ThinkingLevel, + type Tool, + type ToolCall, + type ToolResultMessage, + type TranscriptContext, + validateToolCall, +} from "@earendil-works/pi-ai"; +import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messages"; +import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse"; + +import { getClaudeBinary } from "./binary.ts"; +import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts"; +import { ClaudeCodeError } from "./constants.ts"; +import { addLiveProcess, getLiveProcess, removeLiveProcess, type LiveProcess } from "./live-processes.ts"; +import { createToolBridge, toolNameFromMcp, type PendingToolCall, type ToolBridge } from "./tool-bridge.ts"; +import { toClaudeInput, toStreamJsonLine } from "./transcript.ts"; + +type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv }; + +export type RunClaude = (args: string[], options: RunClaudeOptions) => ChildProcessWithoutNullStreams | null; + +// A held MCP call lasts until pi has run every tool of the message (each capped at 30s by +// tool-execute) and called back; this must outlast that so the CLI never times out first. +const MCP_TOOL_TIMEOUT_MS = 30 * 60_000; + +const PRIVATE_FILE_MODE = 0o600; + +const EFFORTS: Record = { + minimal: "low", + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", + max: "max", +}; + +const STOP_REASONS: Record = { + end_turn: "stop", + pause_turn: "stop", + stop_sequence: "stop", + max_tokens: "length", + tool_use: "toolUse", + refusal: "error", +}; + +const REFUSAL_MESSAGE = "The model refused to complete the request"; +const ABORT_MESSAGE = "Request was aborted"; +const START_FAILED_MESSAGE = "claude CLI failed to start"; +const NO_STOP_REASON_MESSAGE = "claude CLI ended the message without a stop reason"; +const NOT_SIGNED_IN_ERROR = "authentication_failed"; + +export function toEffort(level: ThinkingLevel | undefined): AnthropicEffort { + return level ? EFFORTS[level] : "medium"; +} + +type ClaudeArgsInput = { + model: string; + effort: AnthropicEffort; + systemPromptFile: string; + mcpConfigFile: string; + allowedTools: string[]; +}; + +export function toClaudeArgs(input: ClaudeArgsInput): string[] { + return [ + "-p", + "--input-format", + "stream-json", + "--output-format", + "stream-json", + "--verbose", + "--include-partial-messages", + "--model", + input.model, + "--effort", + input.effort, + "--system-prompt-file", + input.systemPromptFile, + "--tools", + "", + "--strict-mcp-config", + "--mcp-config", + input.mcpConfigFile, + "--setting-sources", + "", + "--disable-slash-commands", + "--no-session-persistence", + "--permission-prompts", + "none", + ...(input.allowedTools.length > 0 ? ["--allowedTools", input.allowedTools.join(",")] : []), + ]; +} + +function createOutput(model: Model): AssistantMessage { + return { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "pending", + timestamp: Date.now(), + }; +} + +function getAssistantErrorMessage(error: string, text: string): string { + return error === NOT_SIGNED_IN_ERROR ? ClaudeCodeError.NotSignedIn : text || error; +} + +class ClaudeTurn { + private readonly output: AssistantMessage; + private readonly contentIndexes = new Map(); + private readonly partialJson = new Map(); + started = false; + finished = false; + + constructor( + private readonly stream: AssistantMessageEventStream, + model: Model, + ) { + this.output = createOutput(model); + } + + handle(event: CliEvent): void { + if (this.finished) return; + + switch (event.type) { + case "messageStart": + this.updateUsage(event.usage); + this.start(); + return; + + case "blockStart": + this.startBlock(event.index, event.block); + return; + + case "textDelta": { + const found = this.getBlock(event.index); + if (!found) return; + + const { contentIndex, block } = found; + if (block.type !== "text") return; + + block.text += event.text; + this.stream.push({ type: "text_delta", contentIndex, delta: event.text, partial: this.output }); + return; + } + + case "thinkingDelta": { + const found = this.getBlock(event.index); + if (!found) return; + + const { contentIndex, block } = found; + if (block.type !== "thinking") return; + + block.thinking += event.thinking; + this.stream.push({ type: "thinking_delta", contentIndex, delta: event.thinking, partial: this.output }); + return; + } + + case "toolInputDelta": { + const found = this.getBlock(event.index); + if (!found) return; + + const { contentIndex, block } = found; + if (block.type !== "toolCall") return; + + const partialJson = (this.partialJson.get(event.index) ?? "") + event.partialJson; + this.partialJson.set(event.index, partialJson); + block.arguments = parseStreamingJson(partialJson); + this.stream.push({ type: "toolcall_delta", contentIndex, delta: event.partialJson, partial: this.output }); + return; + } + + case "blockStop": + this.stopBlock(event.index); + return; + + case "messageDelta": + this.updateUsage(event.usage); + if (event.stopReason) this.setStopReason(event.stopReason); + return; + + case "messageStop": + this.finish(); + return; + + case "assistantError": + this.fail(getAssistantErrorMessage(event.error, event.text), "error"); + return; + + case "resultError": + this.fail(event.text, "error"); + return; + } + } + + getToolCalls(): ToolCall[] { + if (!this.finished || this.output.stopReason !== "toolUse") return []; + + return this.output.content.filter((block) => block.type === "toolCall"); + } + + fail(message: string, reason: "aborted" | "error"): void { + if (this.finished) return; + + this.finished = true; + this.output.stopReason = reason; + this.output.errorMessage = message; + this.stream.push({ type: "error", reason, error: this.output }); + this.stream.end(); + } + + private start(): void { + if (this.started) return; + + this.started = true; + this.stream.push({ type: "start", partial: this.output }); + } + + private startBlock(index: number, block: CliBlock): void { + const contentIndex = this.output.content.length; + this.contentIndexes.set(index, contentIndex); + + switch (block.type) { + case "text": + this.output.content.push({ type: "text", text: "" }); + this.stream.push({ type: "text_start", contentIndex, partial: this.output }); + return; + + case "thinking": + this.output.content.push({ type: "thinking", thinking: "" }); + this.stream.push({ type: "thinking_start", contentIndex, partial: this.output }); + return; + + case "toolUse": + this.output.content.push({ type: "toolCall", id: block.id, name: toolNameFromMcp(block.name), arguments: {} }); + this.stream.push({ type: "toolcall_start", contentIndex, partial: this.output }); + return; + } + } + + private getBlock(index: number): { contentIndex: number; block: AssistantMessage["content"][number] } | null { + const contentIndex = this.contentIndexes.get(index); + if (contentIndex === undefined) return null; + + const block = this.output.content[contentIndex]; + return block ? { contentIndex, block } : null; + } + + private stopBlock(index: number): void { + const found = this.getBlock(index); + if (!found) return; + + const { contentIndex, block } = found; + this.contentIndexes.delete(index); + + switch (block.type) { + case "text": + this.stream.push({ type: "text_end", contentIndex, content: block.text, partial: this.output }); + return; + + case "thinking": + this.stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: this.output }); + return; + + case "toolCall": + Object.assign(block, parseToolCallArguments(this.partialJson.get(index) ?? "")); + this.partialJson.delete(index); + this.stream.push({ type: "toolcall_end", contentIndex, toolCall: block satisfies ToolCall, partial: this.output }); + return; + } + } + + private updateUsage(usage: CliUsage): void { + Object.assign(this.output.usage, usage); + + const { input, output, cacheRead, cacheWrite } = this.output.usage; + this.output.usage.totalTokens = input + output + cacheRead + cacheWrite; + } + + private setStopReason(stopReason: string): void { + const mapped = STOP_REASONS[stopReason]; + + if (!mapped) { + this.output.stopReason = "error"; + this.output.errorMessage = `Unhandled stop reason: ${stopReason}`; + return; + } + + this.output.stopReason = mapped; + } + + private finish(): void { + const { stopReason } = this.output; + + if (stopReason === "error") { + this.fail(this.output.errorMessage ?? REFUSAL_MESSAGE, "error"); + return; + } + + if (stopReason === "pending" || stopReason === "aborted") { + this.fail(this.output.errorMessage ?? NO_STOP_REASON_MESSAGE, "error"); + return; + } + + this.finished = true; + this.stream.push({ type: "done", reason: stopReason, message: this.output }); + this.stream.end(); + } +} + +type TurnFiles = { cwd: string; privateDir: string; systemPromptFile: string; mcpConfigFile: string }; + +async function createTurnFiles(systemPrompt: string, mcpConfig: string): Promise { + const cwd = await mkdtemp(join(tmpdir(), "claude-code-cwd-")); + const privateDir = await mkdtemp(join(tmpdir(), "claude-code-private-")); + + const systemPromptFile = join(privateDir, "system-prompt.md"); + const mcpConfigFile = join(privateDir, "mcp.json"); + + await writeFile(systemPromptFile, systemPrompt, { mode: PRIVATE_FILE_MODE }); + await writeFile(mcpConfigFile, mcpConfig, { mode: PRIVATE_FILE_MODE }); + + return { cwd, privateDir, systemPromptFile, mcpConfigFile }; +} + +async function removeTurnResources(bridge: ToolBridge, files: TurnFiles): Promise { + await bridge.close(); + await rm(files.cwd, { recursive: true, force: true }); + await rm(files.privateDir, { recursive: true, force: true }); +} + +type UnansweredToolCall = { toolCall: ToolCall; result: ToolResultMessage }; + +// The CLI answers calls to undeclared tools or with invalid arguments itself, without reaching the bridge. +function isValidToolCall(tools: Tool[], toolCall: ToolCall): boolean { + try { + validateToolCall(tools, toolCall); + return true; + } catch { + return false; + } +} + +function isSameToolCall(toolCall: ToolCall, call: PendingToolCall): boolean { + return toolCall.name === call.name && isDeepStrictEqual(toolCall.arguments, call.arguments); +} + +class ClaudeProcess implements LiveProcess { + toolCallIds: string[] = []; + private stderr = ""; + private closed = false; + private signal: AbortSignal | undefined; + + constructor( + private readonly child: ChildProcessWithoutNullStreams, + private readonly bridge: ToolBridge, + private readonly files: TurnFiles, + private readonly tools: Tool[], + private turn: ClaudeTurn, + ) {} + + start(stdinLine: string, signal: AbortSignal | undefined): void { + this.child.once("close", (code: number | null) => this.handleClose(`claude CLI exited with code ${code}`)); + this.child.once("error", (error) => { + console.error("claude CLI process failed:", error); + this.turn.fail(START_FAILED_MESSAGE, "error"); + this.handleClose(START_FAILED_MESSAGE); + }); + + this.child.stderr.setEncoding("utf8"); + this.child.stderr.on("data", (chunk: string) => (this.stderr += chunk)); + + this.child.stdin.on("error", (error) => console.error("claude CLI stdin closed early:", error)); + this.child.stdin.end(`${stdinLine}\n`); + + createInterface({ input: this.child.stdout }).on("line", (line) => this.handleLine(line)); + + this.setSignal(signal); + } + + continueTurn(turn: ClaudeTurn, toolResults: ToolResultMessage[], signal: AbortSignal | undefined): void { + removeLiveProcess(this); + + const unanswered = this.turn.getToolCalls().flatMap((toolCall) => { + const result = toolResults.find((toolResult) => toolResult.toolCallId === toolCall.id); + return result ? [{ toolCall, result }] : []; + }); + + this.turn = turn; + this.setSignal(signal); + + this.answerToolCalls(turn, unanswered).catch((error: unknown) => { + console.error("claude CLI tool results failed:", error); + turn.fail(START_FAILED_MESSAGE, "error"); + this.kill(); + }); + } + + kill(): void { + removeLiveProcess(this); + this.child.kill(); + } + + private readonly abort = () => { + this.turn.fail(ABORT_MESSAGE, "aborted"); + this.kill(); + }; + + private setSignal(signal: AbortSignal | undefined): void { + this.signal?.removeEventListener("abort", this.abort); + this.signal = signal; + signal?.addEventListener("abort", this.abort, { once: true }); + + if (signal?.aborted) this.abort(); + } + + private handleLine(line: string): void { + const event = parseCliLine(line); + if (!event) return; + + // Output after a finished message means the CLI moved on without waiting for our tool results. + if (this.turn.finished) { + this.kill(); + return; + } + + this.turn.handle(event); + if (!this.turn.finished) return; + + const toolCalls = this.turn.getToolCalls(); + this.toolCallIds = toolCalls.map((toolCall) => toolCall.id); + + const reachesBridge = toolCalls.length > 0 && toolCalls.every((toolCall) => isValidToolCall(this.tools, toolCall)); + if (!reachesBridge) { + this.kill(); + return; + } + + addLiveProcess(this); + } + + private async answerToolCalls(turn: ClaudeTurn, unanswered: UnansweredToolCall[]): Promise { + while (unanswered.length > 0 && !turn.started) { + const pending = this.bridge.getPendingToolCalls(); + let answered = false; + + for (const call of pending) { + const index = unanswered.findIndex(({ toolCall }) => isSameToolCall(toolCall, call)); + const match = unanswered[index]; + if (!match) continue; + + this.bridge.resolveToolCall(call, match.result); + unanswered.splice(index, 1); + answered = true; + } + + if (answered) continue; + + // Rejects only once the bridge has closed, which means the process is already gone. + const next = await this.bridge.waitForToolCalls(pending.length + 1).catch(() => null); + if (!next) return; + } + } + + private handleClose(message: string): void { + if (this.closed) return; + + this.closed = true; + removeLiveProcess(this); + this.signal?.removeEventListener("abort", this.abort); + + if (this.stderr.trim()) console.error("claude CLI stderr:", this.stderr.trim()); + + this.turn.fail(message, "error"); + + removeTurnResources(this.bridge, this.files).catch((error: unknown) => { + console.error("claude CLI cleanup failed:", error); + }); + } +} + +function continueLiveProcess(turn: ClaudeTurn, messages: Message[], signal: AbortSignal | undefined): boolean { + const assistantIndex = messages.findLastIndex((message) => message.role === "assistant"); + const assistant = messages[assistantIndex]; + if (assistant?.role !== "assistant") return false; + + const toolCallIds = assistant.content.flatMap((block) => (block.type === "toolCall" ? [block.id] : [])); + const process = getLiveProcess(toolCallIds); + if (!(process instanceof ClaudeProcess)) return false; + + const trailing = messages.slice(assistantIndex + 1); + const toolResults = trailing.filter((message) => message.role === "toolResult"); + + const onlyToolResults = toolResults.length === trailing.length; + const answersEveryCall = toolCallIds.every((id) => toolResults.some((toolResult) => toolResult.toolCallId === id)); + + if (!onlyToolResults || !answersEveryCall) { + process.kill(); + return false; + } + + process.continueTurn(turn, toolResults, signal); + return true; +} + +// Either one takes precedence over the logged-in subscription, so a key saved on the Keys page +// would bill turns to the API account. +const API_CREDENTIAL_ENV_NAMES = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]; + +function toClaudeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const claudeEnv: NodeJS.ProcessEnv = { ...env, MCP_TOOL_TIMEOUT: String(MCP_TOOL_TIMEOUT_MS) }; + + for (const name of API_CREDENTIAL_ENV_NAMES) delete claudeEnv[name]; + + return claudeEnv; +} + +async function startProcess( + runClaude: RunClaude, + turn: ClaudeTurn, + model: Model, + context: TranscriptContext, + options: SimpleStreamOptions | undefined, +): Promise { + const input = toClaudeInput(context.messages); + const bridge = await createToolBridge(input.tools); + const files = await createTurnFiles(input.systemPrompt, bridge.mcpConfig); + + let child: ChildProcessWithoutNullStreams | null; + try { + const args = toClaudeArgs({ + model: model.id, + effort: toEffort(options?.reasoning), + systemPromptFile: files.systemPromptFile, + mcpConfigFile: files.mcpConfigFile, + allowedTools: bridge.allowedTools, + }); + child = runClaude(args, { cwd: files.cwd, env: toClaudeEnv(process.env) }); + } catch (error) { + await removeTurnResources(bridge, files); + throw error; + } + + if (!child) { + turn.fail(ClaudeCodeError.CliNotFound, "error"); + await removeTurnResources(bridge, files); + return; + } + + new ClaudeProcess(child, bridge, files, input.tools, turn).start(toStreamJsonLine(input.userContent), options?.signal); +} + +export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunction { + return (model, context, options) => { + const stream = createAssistantMessageEventStream(); + const turn = new ClaudeTurn(stream, model); + + if (continueLiveProcess(turn, context.messages, options?.signal)) return stream; + + startProcess(runClaude, turn, model, context, options).catch((error: unknown) => { + console.error("claude CLI turn failed:", error); + turn.fail(START_FAILED_MESSAGE, "error"); + }); + + return stream; + }; +} + +function spawnClaude(args: string[], options: RunClaudeOptions): ChildProcessWithoutNullStreams | null { + const binary = getClaudeBinary(); + + return binary ? spawn(binary, args, { ...options, stdio: "pipe" }) : null; +} + +export const streamClaudeCode = createClaudeCodeStreamFunction(spawnClaude); diff --git a/tests/claude-code-stream.test.ts b/tests/claude-code-stream.test.ts new file mode 100644 index 0000000..96acc52 --- /dev/null +++ b/tests/claude-code-stream.test.ts @@ -0,0 +1,781 @@ +/** + * Claude Code stream function (src/agent/claude-code/stream.ts) and CLI line + * parser (src/agent/claude-code/cli-events.ts). + * + * The runClaude seam spawns tests/fixtures/claude-code/fake-claude.mjs, which + * replays recorded or synthetic stream-json lines, so the real process + * plumbing (argv, stdin, stdout lines, stderr, exit, kill) runs end to end. + */ +import assert from "node:assert/strict"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { once } from "node:events"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, sep } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import test, { type TestContext } from "node:test"; + +import type { + AssistantMessage, + AssistantMessageEvent, + AssistantMessageEventStream, + JsonObject, + Message, + SimpleStreamOptions, + StreamFunction, + Api, + Tool, + ToolResultMessage, +} from "@earendil-works/pi-ai"; +import { normalizeContext } from "@earendil-works/pi-ai/utils/transcript"; + +import { parseCliLine } from "../src/agent/claude-code/cli-events.ts"; +import { ClaudeCodeError } from "../src/agent/claude-code/constants.ts"; +import { LIVE_PROCESS_IDLE_TIMEOUT_MS, liveProcessIdleTimeoutMs } from "../src/agent/claude-code/live-processes.ts"; +import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; +import { createClaudeCodeStreamFunction, toClaudeArgs, toEffort, type RunClaude } from "../src/agent/claude-code/stream.ts"; +import { toStreamJsonLine } from "../src/agent/claude-code/transcript.ts"; + +const FAKE_CLAUDE = fileURLToPath(new URL("./fixtures/claude-code/fake-claude.mjs", import.meta.url)); +const FIXTURES = fileURLToPath(new URL("./fixtures/claude-code/", import.meta.url)); +const HOLD = JSON.stringify({ fake: "hold" }); +const REPLY = JSON.stringify({ fake: "reply" }); +const CLEANUP_TIMEOUT_MS = 2000; + +const READ_TOOL: Tool = { name: "read", description: "Read a file", parameters: { type: "object", properties: {} } }; +const HI: Message = { role: "user", content: "hi", timestamp: 0 }; + +type ToolUse = { id: string; name: string; arguments: JsonObject }; + +type RunCall = { + args: string[]; + cwd: string; + cwdEntries: string[]; + env: NodeJS.ProcessEnv; + systemPrompt: string; + mcpConfig: string; + mcpConfigMode: number; + child: ChildProcessWithoutNullStreams; +}; + +type ScenarioRun = { events: AssistantMessageEvent[]; calls: RunCall[] }; + +function readFixtureLines(name: string): string[] { + return readFileSync(join(FIXTURES, name), "utf8").split("\n").filter(Boolean); +} + +function getFlagValue(args: string[], flag: string): string { + const value = args[args.indexOf(flag) + 1]; + assert.ok(args.includes(flag) && value !== undefined, `missing ${flag}`); + + return value; +} + +function streamEvent(event: object): string { + return JSON.stringify({ type: "stream_event", event }); +} + +function exitWith(code: number, stderr: string): string { + return JSON.stringify({ fake: "exit", code, stderr }); +} + +function getToolScenario(): string[] { + const lines = readFixtureLines("tool.jsonl"); + const firstMessageStop = lines.findIndex((line) => line.includes('"type":"message_stop"')); + return [...lines.slice(0, firstMessageStop + 1).filter((line) => !line.startsWith('{"type":"user"')), HOLD]; +} + +function createFakeRun( + t: TestContext, + scenario: string[], + extraEnv: Record = {}, +): { runClaude: RunClaude; calls: RunCall[] } { + const dir = mkdtempSync(join(tmpdir(), "claude-code-test-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const scenarioFile = join(dir, "scenario.jsonl"); + writeFileSync(scenarioFile, `${scenario.join("\n")}\n`); + + const calls: RunCall[] = []; + + const runClaude: RunClaude = (args, options) => { + const mcpConfigFile = getFlagValue(args, "--mcp-config"); + const child = spawn(process.execPath, [FAKE_CLAUDE, ...args], { + cwd: options.cwd, + env: { ...options.env, FAKE_CLAUDE_SCENARIO: scenarioFile, ...extraEnv }, + stdio: "pipe", + }); + t.after(() => child.kill()); + calls.push({ + args, + cwd: options.cwd, + cwdEntries: readdirSync(options.cwd), + env: options.env, + systemPrompt: readFileSync(getFlagValue(args, "--system-prompt-file"), "utf8"), + mcpConfig: readFileSync(mcpConfigFile, "utf8"), + mcpConfigMode: statSync(mcpConfigFile).mode & 0o777, + child, + }); + return child; + }; + + return { runClaude, calls }; +} + +async function collectEvents( + stream: AssistantMessageEventStream, + onEvent: (event: AssistantMessageEvent) => void = () => {}, +): Promise { + const events: AssistantMessageEvent[] = []; + + for await (const event of stream) { + events.push(event); + onEvent(event); + } + + return events; +} + +async function runScenario( + t: TestContext, + scenario: string[], + options?: SimpleStreamOptions, + extraEnv?: Record, +): Promise { + const { runClaude, calls } = createFakeRun(t, scenario, extraEnv); + + const model = CLAUDE_CODE_MODELS[0]; + assert.ok(model); + + const context = normalizeContext({ + systemPrompt: "You are a tutor.", + messages: [{ role: "user", content: "hi", timestamp: 0 }], + tools: [READ_TOOL], + }); + const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(model, context, options)); + + return { events, calls }; +} + +function toToolUseMessage(toolUses: ToolUse[]): string[] { + return [ + streamEvent({ type: "message_start", message: { usage: {} } }), + ...toolUses.flatMap((toolUse, index) => [ + streamEvent({ + type: "content_block_start", + index, + content_block: { type: "tool_use", id: toolUse.id, name: `mcp__learnharness__${toolUse.name}`, input: {} }, + }), + streamEvent({ + type: "content_block_delta", + index, + delta: { type: "input_json_delta", partial_json: JSON.stringify(toolUse.arguments) }, + }), + streamEvent({ type: "content_block_stop", index }), + ]), + streamEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }), + streamEvent({ type: "message_stop" }), + ]; +} + +function callTool(toolUse: ToolUse): string { + return JSON.stringify({ fake: "call", name: toolUse.name, arguments: toolUse.arguments }); +} + +function toToolResult(toolUse: ToolUse, text: string): ToolResultMessage { + return { + role: "toolResult", + toolCallId: toolUse.id, + toolName: toolUse.name, + content: [{ type: "text", text }], + isError: false, + timestamp: 0, + }; +} + +function getModel() { + const model = CLAUDE_CODE_MODELS[0]; + assert.ok(model); + return model; +} + +async function runTurn( + streamFunction: StreamFunction, + messages: Message[], + options?: SimpleStreamOptions, +): Promise { + const context = normalizeContext({ systemPrompt: "You are a tutor.", messages, tools: [READ_TOOL] }); + + const done = getLastEvent(await collectEvents(streamFunction(getModel(), context, options))); + + assert.ok(done.type === "done", `turn ended with ${done.type}`); + + return done.message; +} + +function getText(message: AssistantMessage): string { + return message.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join(""); +} + +function getMcpUrl(call: RunCall): string { + const mcpConfig = JSON.parse(call.mcpConfig) as { mcpServers: Record }; + const url = mcpConfig.mcpServers.learnharness?.url; + assert.ok(url); + return url; +} + +function getOnlyCall(calls: RunCall[]): RunCall { + assert.equal(calls.length, 1); + const [call] = calls; + assert.ok(call); + return call; +} + +function getLastEvent(events: AssistantMessageEvent[]): AssistantMessageEvent { + const event = events.at(-1); + assert.ok(event); + return event; +} + +function getErrorMessage(events: AssistantMessageEvent[]): string | undefined { + const event = getLastEvent(events); + assert.equal(event.type, "error"); + return event.type === "error" ? event.error.errorMessage : undefined; +} + +async function waitForExit(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + + await assert.doesNotReject(once(child, "exit", { signal: AbortSignal.timeout(CLEANUP_TIMEOUT_MS) }), "claude CLI process was not killed"); +} + +async function waitForRemoval(path: string): Promise { + const deadline = Date.now() + CLEANUP_TIMEOUT_MS; + + while (existsSync(path)) { + assert.ok(Date.now() < deadline, `${path} was not removed`); + await sleep(10); + } +} + +async function assertCleanedUp(call: RunCall): Promise { + await waitForExit(call.child); + await waitForRemoval(call.cwd); + await waitForRemoval(dirname(getFlagValue(call.args, "--system-prompt-file"))); +} + +test("toClaudeArgs includes every isolation flag from Global Constraints", () => { + const input = { + model: "opus", + effort: "high", + systemPromptFile: "/private/system-prompt.md", + mcpConfigFile: "/private/mcp.json", + allowedTools: ["mcp__learnharness__read", "mcp__learnharness__write"], + } as const; + + assert.deepEqual(toClaudeArgs({ ...input, allowedTools: [...input.allowedTools] }), [ + "-p", + "--input-format", + "stream-json", + "--output-format", + "stream-json", + "--verbose", + "--include-partial-messages", + "--model", + "opus", + "--effort", + "high", + "--system-prompt-file", + "/private/system-prompt.md", + "--tools", + "", + "--strict-mcp-config", + "--mcp-config", + "/private/mcp.json", + "--setting-sources", + "", + "--disable-slash-commands", + "--no-session-persistence", + "--permission-prompts", + "none", + "--allowedTools", + "mcp__learnharness__read,mcp__learnharness__write", + ]); + + assert.equal(toClaudeArgs({ ...input, allowedTools: [] }).includes("--allowedTools"), false); +}); + +test('toEffort maps low..max verbatim, minimal to "low", undefined to "medium"', () => { + assert.deepEqual( + (["low", "medium", "high", "xhigh", "max"] as const).map((level) => toEffort(level)), + ["low", "medium", "high", "xhigh", "max"], + ); + + assert.equal(toEffort("minimal"), "low"); + assert.equal(toEffort(undefined), "medium"); +}); + +test("text scenario streams start, text_start, text_delta…, text_end, done(stop)", async (t) => { + const { events, calls } = await runScenario(t, readFixtureLines("text.jsonl")); + + assert.deepEqual( + events.map((event) => event.type), + ["start", "text_start", "text_delta", "text_end", "done"], + ); + + const done = getLastEvent(events); + assert.ok(done.type === "done"); + assert.equal(done.reason, "stop"); + assert.equal(done.message.stopReason, "stop"); + assert.deepEqual(done.message.content, [{ type: "text", text: "hello there" }]); + assert.equal(done.message.provider, "claude-code"); + assert.equal(done.message.api, "claude-code-cli"); + assert.equal(done.message.model, "opus"); + + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("thinking deltas map to thinking_*", async (t) => { + const { events } = await runScenario(t, [ + streamEvent({ type: "message_start", message: { usage: { input_tokens: 3, output_tokens: 1 } } }), + streamEvent({ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }), + streamEvent({ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "Sockets are " } }), + streamEvent({ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "endpoints." } }), + streamEvent({ type: "content_block_stop", index: 0 }), + streamEvent({ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }), + streamEvent({ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "An endpoint." } }), + streamEvent({ type: "content_block_stop", index: 1 }), + streamEvent({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 9 } }), + streamEvent({ type: "message_stop" }), + ]); + + assert.deepEqual( + events.map((event) => event.type), + ["start", "thinking_start", "thinking_delta", "thinking_delta", "thinking_end", "text_start", "text_delta", "text_end", "done"], + ); + + const done = getLastEvent(events); + assert.ok(done.type === "done"); + assert.deepEqual(done.message.content, [ + { type: "thinking", thinking: "Sockets are endpoints." }, + { type: "text", text: "An endpoint." }, + ]); +}); + +test("tool scenario ends with toolcall_end (Claude's tool_use id, unprefixed name) and done(toolUse)", async (t) => { + const { events } = await runScenario(t, getToolScenario()); + + assert.deepEqual( + events.map((event) => event.type), + ["start", "toolcall_start", "toolcall_delta", "toolcall_delta", "toolcall_delta", "toolcall_end", "done"], + ); + + const toolCallEnd = events.find((event) => event.type === "toolcall_end"); + assert.ok(toolCallEnd?.type === "toolcall_end"); + assert.deepEqual(toolCallEnd.toolCall, { + type: "toolCall", + id: "toolu_01JBB4cJsPa585FWb1XrfbNX", + name: "get_weather", + arguments: { city: "Paris" }, + }); + + const done = getLastEvent(events); + assert.ok(done.type === "done"); + assert.equal(done.reason, "toolUse"); + assert.deepEqual(done.message.content, [toolCallEnd.toolCall]); +}); + +test("unknown tool name and bad JSON args still surface as tool calls", async (t) => { + const { events } = await runScenario(t, [ + streamEvent({ type: "message_start", message: { usage: {} } }), + streamEvent({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "toolu_a", name: "mcp__learnharness__nope", input: {} }, + }), + streamEvent({ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"path": ' } }), + streamEvent({ type: "content_block_stop", index: 0 }), + streamEvent({ type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "toolu_b", name: "Bash", input: {} } }), + streamEvent({ type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: '{"command":"ls"}' } }), + streamEvent({ type: "content_block_stop", index: 1 }), + streamEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }), + streamEvent({ type: "message_stop" }), + HOLD, + ]); + + const toolCalls = events.flatMap((event) => (event.type === "toolcall_end" ? [event.toolCall] : [])); + assert.deepEqual( + toolCalls.map((call) => ({ id: call.id, name: call.name, arguments: call.arguments })), + [ + { id: "toolu_a", name: "nope", arguments: {} }, + { id: "toolu_b", name: "Bash", arguments: { command: "ls" } }, + ], + ); + + assert.match(toolCalls[0]?.argumentsError ?? "", /\{"path": /); + assert.equal(toolCalls[1]?.argumentsError, undefined); + + const done = getLastEvent(events); + assert.ok(done.type === "done"); + assert.equal(done.reason, "toolUse"); +}); + +test("usage maps input/output/cacheRead/cacheWrite with zero cost, message_delta overriding message_start", async (t) => { + const { events } = await runScenario(t, getToolScenario()); + const done = getLastEvent(events); + assert.ok(done.type === "done"); + assert.deepEqual(done.message.usage, { + input: 2, + output: 59, + cacheRead: 0, + cacheWrite: 1102, + totalTokens: 1163, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }); +}); + +test('missing binary → error "claude CLI not found"', async () => { + const model = CLAUDE_CODE_MODELS[0]; + assert.ok(model); + + let cwd = ""; + const runClaude: RunClaude = (_args, options) => { + cwd = options.cwd; + return null; + }; + + const context = normalizeContext({ systemPrompt: "p", messages: [{ role: "user", content: "hi", timestamp: 0 }] }); + const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(model, context)); + + assert.deepEqual( + events.map((event) => event.type), + ["error"], + ); + + assert.equal(getErrorMessage(events), ClaudeCodeError.CliNotFound); + await waitForRemoval(cwd); +}); + +test('signed-out fixture → error "not signed in"', async (t) => { + const { events, calls } = await runScenario(t, [...readFixtureLines("signed-out.jsonl"), exitWith(1, "")]); + assert.equal(getErrorMessage(events), ClaudeCodeError.NotSignedIn); + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("other assistant errors surface their text", async (t) => { + const assistantError = JSON.stringify({ + type: "assistant", + error: "rate_limit", + message: { content: [{ type: "text", text: "API Error: Rate limit reached" }] }, + }); + const { events } = await runScenario(t, [assistantError, exitWith(1, "")]); + assert.equal(getErrorMessage(events), "API Error: Rate limit reached"); +}); + +test("result error before message_stop surfaces the result text", async (t) => { + const resultError = JSON.stringify({ + type: "result", + subtype: "error_during_execution", + is_error: true, + result: "Credit balance is too low", + }); + const { events } = await runScenario(t, [resultError, HOLD]); + assert.equal(getErrorMessage(events), "Credit balance is too low"); +}); + +test("result error without result text surfaces its errors", async (t) => { + const resultError = JSON.stringify({ + type: "result", + subtype: "error_max_turns", + is_error: true, + errors: ["Reached maximum number of turns"], + }); + const { events } = await runScenario(t, [resultError, HOLD]); + assert.equal(getErrorMessage(events), "Reached maximum number of turns"); +}); + +test("result error without result text or errors surfaces its subtype", async (t) => { + const resultError = JSON.stringify({ type: "result", subtype: "error_during_execution", is_error: true }); + const { events } = await runScenario(t, [resultError, HOLD]); + assert.equal(getErrorMessage(events), "claude CLI failed: error_during_execution"); +}); + +test("refusal stop reason → error", async (t) => { + const { events } = await runScenario(t, [ + streamEvent({ type: "message_start", message: { usage: {} } }), + streamEvent({ type: "message_delta", delta: { stop_reason: "refusal" }, usage: {} }), + streamEvent({ type: "message_stop" }), + ]); + assert.equal(getErrorMessage(events), "The model refused to complete the request"); +}); + +test("non-zero exit mid-stream → error naming the exit code, stderr logged not shown, process and temp dirs gone", async (t) => { + const consoleError = t.mock.method(console, "error", () => {}); + const midStream = readFixtureLines("text.jsonl").slice(0, 5); + const { events, calls } = await runScenario(t, [...midStream, exitWith(1, "Error: ENOENT /home/someone/.claude/x\n at main\n")]); + + assert.deepEqual( + events.map((event) => event.type), + ["start", "text_start", "text_delta", "error"], + ); + + assert.equal(getErrorMessage(events), "claude CLI exited with code 1"); + assert.ok( + consoleError.mock.calls.some((call) => call.arguments.some((argument) => String(argument).includes("ENOENT /home/someone/.claude/x"))), + ); + + const error = getLastEvent(events); + assert.ok(error.type === "error"); + assert.equal(error.reason, "error"); + assert.deepEqual(error.error.content, [{ type: "text", text: "hello there" }]); + + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("exit without stderr names the exit code", async (t) => { + const { events } = await runScenario(t, [exitWith(3, "")]); + assert.equal(getErrorMessage(events), "claude CLI exited with code 3"); +}); + +test("abort kills the process and ends with error(aborted)", async (t) => { + const { runClaude, calls } = createFakeRun(t, [streamEvent({ type: "message_start", message: { usage: {} } }), HOLD]); + const model = CLAUDE_CODE_MODELS[0]; + assert.ok(model); + const controller = new AbortController(); + const context = normalizeContext({ systemPrompt: "p", messages: [{ role: "user", content: "hi", timestamp: 0 }] }); + const stream = createClaudeCodeStreamFunction(runClaude)(model, context, { signal: controller.signal }); + + const events = await collectEvents(stream, (event) => { + if (event.type === "start") controller.abort(); + }); + + assert.deepEqual( + events.map((event) => event.type), + ["start", "error"], + ); + + const error = getLastEvent(events); + assert.ok(error.type === "error"); + assert.equal(error.reason, "aborted"); + + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("cwd is an empty temp dir; prompt and mcp config live in a separate private dir", async (t) => { + const { calls } = await runScenario(t, readFixtureLines("text.jsonl")); + const call = getOnlyCall(calls); + + assert.deepEqual(call.cwdEntries, []); + assert.ok(call.cwd.startsWith(tmpdir())); + + const privateDir = dirname(getFlagValue(call.args, "--system-prompt-file")); + assert.notEqual(privateDir, call.cwd); + assert.ok(!privateDir.startsWith(call.cwd + sep)); + assert.equal(dirname(getFlagValue(call.args, "--mcp-config")), privateDir); + assert.equal(call.systemPrompt, "You are a tutor."); + assert.equal(call.mcpConfigMode, 0o600); + + const mcpConfig = JSON.parse(call.mcpConfig) as { mcpServers: Record }; + assert.match(mcpConfig.mcpServers.learnharness?.url ?? "", /^http:\/\/127\.0\.0\.1:\d+\/mcp$/); +}); + +test("spawns with the model alias, effort, bridge tools, MCP_TOOL_TIMEOUT and the caller's HOME", async (t) => { + const { calls } = await runScenario(t, readFixtureLines("text.jsonl"), { reasoning: "xhigh" }); + const call = getOnlyCall(calls); + assert.equal(getFlagValue(call.args, "--model"), "opus"); + assert.equal(getFlagValue(call.args, "--effort"), "xhigh"); + assert.equal(getFlagValue(call.args, "--allowedTools"), "mcp__learnharness__read"); + assert.equal(call.env.MCP_TOOL_TIMEOUT, "1800000"); + assert.equal(call.env.HOME, process.env.HOME); + assert.equal(call.env.CLAUDE_CONFIG_DIR, process.env.CLAUDE_CONFIG_DIR); +}); + +test("spawns without Anthropic API credentials so turns stay on the subscription", async (t) => { + const saved = { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN }; + t.after(() => { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); + + process.env.ANTHROPIC_API_KEY = "sk-ant-test"; + process.env.ANTHROPIC_AUTH_TOKEN = "token-test"; + + const { calls } = await runScenario(t, readFixtureLines("text.jsonl")); + const call = getOnlyCall(calls); + + assert.equal(call.env.ANTHROPIC_API_KEY, undefined); + assert.equal(call.env.ANTHROPIC_AUTH_TOKEN, undefined); +}); + +test("writes the transcript as one stream-json user line on stdin", async (t) => { + const stdinFile = join(mkdtempSync(join(tmpdir(), "claude-code-stdin-")), "stdin.jsonl"); + t.after(() => rmSync(dirname(stdinFile), { recursive: true, force: true })); + + await runScenario(t, readFixtureLines("text.jsonl"), undefined, { FAKE_CLAUDE_STDIN: stdinFile }); + + assert.equal(readFileSync(stdinFile, "utf8"), `${toStreamJsonLine([{ type: "text", text: "hi" }])}\n`); +}); + +test("parseCliLine ignores non-JSON, system, rate-limit and successful result lines", () => { + assert.equal(parseCliLine("not json"), null); + assert.equal(parseCliLine("null"), null); + assert.equal(parseCliLine('{"type":"system","subtype":"init"}'), null); + assert.equal(parseCliLine('{"type":"rate_limit_event"}'), null); + assert.equal(parseCliLine('{"type":"result","is_error":false,"result":"hello there"}'), null); + assert.equal(parseCliLine('{"type":"assistant","error":null,"message":{"content":[{"type":"text","text":"hello there"}]}}'), null); +}); + +test("trailing toolResults matching a live process continue it: held calls answered, new assistant message streamed, no new spawn", async (t) => { + const readA: ToolUse = { id: "toolu_continue_a", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA]), callTool(readA), REPLY]); + const streamFunction = createClaudeCodeStreamFunction(runClaude); + const toolMessage = await runTurn(streamFunction, [HI]); + + const reply = await runTurn(streamFunction, [HI, toolMessage, toToolResult(readA, "contents of a")]); + + assert.equal(toolMessage.stopReason, "toolUse"); + assert.equal(getText(reply), "contents of a"); + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("multiple tool calls in one message are all answered by id", async (t) => { + const readA: ToolUse = { id: "toolu_many_a", name: "read", arguments: { path: "a" } }; + const readB: ToolUse = { id: "toolu_many_b", name: "read", arguments: { path: "b" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA, readB]), callTool(readB), callTool(readA), REPLY]); + const streamFunction = createClaudeCodeStreamFunction(runClaude); + const toolMessage = await runTurn(streamFunction, [HI]); + + const reply = await runTurn(streamFunction, [HI, toolMessage, toToolResult(readA, "A"), toToolResult(readB, "B")]); + + assert.equal(getText(reply), "B | A"); + assert.equal(calls.length, 1); +}); + +test("partial id match starts fresh and kills the old process", async (t) => { + const readA: ToolUse = { id: "toolu_partial_a", name: "read", arguments: { path: "a" } }; + const readB: ToolUse = { id: "toolu_partial_b", name: "read", arguments: { path: "b" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA, readB]), callTool(readA), callTool(readB), REPLY]); + const streamFunction = createClaudeCodeStreamFunction(runClaude); + const toolMessage = await runTurn(streamFunction, [HI]); + + await runTurn(streamFunction, [HI, toolMessage, toToolResult(readA, "A")]); + + assert.equal(calls.length, 2); + const [oldCall] = calls; + assert.ok(oldCall); + await assertCleanedUp(oldCall); +}); + +test("user message after tool results starts fresh with unfinished calls in the replay", async (t) => { + const stdinFile = join(mkdtempSync(join(tmpdir(), "claude-code-stdin-")), "stdin.jsonl"); + t.after(() => rmSync(dirname(stdinFile), { recursive: true, force: true })); + + const readA: ToolUse = { id: "toolu_steer_a", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA]), callTool(readA), REPLY], { FAKE_CLAUDE_STDIN: stdinFile }); + const streamFunction = createClaudeCodeStreamFunction(runClaude); + const toolMessage = await runTurn(streamFunction, [HI]); + const steer: Message = { role: "user", content: "wait, explain sockets first", timestamp: 0 }; + + await runTurn(streamFunction, [HI, toolMessage, toToolResult(readA, "A"), steer]); + + assert.equal(calls.length, 2); + + const [oldCall] = calls; + assert.ok(oldCall); + await assertCleanedUp(oldCall); + + const stdin = readFileSync(stdinFile, "utf8"); + assert.ok(stdin.includes('[tool call read] {\\"path\\":\\"a\\"}'), stdin); + assert.ok(stdin.includes("wait, explain sockets first"), stdin); +}); + +test("output while waiting for tool results kills the process", async (t) => { + const readA: ToolUse = { id: "toolu_moved_on_a", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA]), REPLY, HOLD]); + + await runTurn(createClaudeCodeStreamFunction(runClaude), [HI]); + + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("an undeclared tool call is not kept waiting, since the CLI answers it itself", async (t) => { + const readA: ToolUse = { id: "toolu_undeclared_a", name: "read", arguments: { path: "a" } }; + const nope: ToolUse = { id: "toolu_undeclared_nope", name: "nope", arguments: {} }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA, nope]), HOLD]); + + const toolMessage = await runTurn(createClaudeCodeStreamFunction(runClaude), [HI]); + + assert.equal(toolMessage.stopReason, "toolUse"); + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("a tool call with invalid JSON arguments is not kept waiting, since the CLI answers it itself", async (t) => { + const { runClaude, calls } = createFakeRun(t, [ + streamEvent({ type: "message_start", message: { usage: {} } }), + streamEvent({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "toolu_bad_json", name: "mcp__learnharness__read", input: {} }, + }), + streamEvent({ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"path": ' } }), + streamEvent({ type: "content_block_stop", index: 0 }), + streamEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }), + streamEvent({ type: "message_stop" }), + HOLD, + ]); + + const toolMessage = await runTurn(createClaudeCodeStreamFunction(runClaude), [HI]); + + assert.equal(toolMessage.stopReason, "toolUse"); + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("abort while waiting kills the process and closes the bridge", async (t) => { + const readA: ToolUse = { id: "toolu_abort_a", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA]), callTool(readA), REPLY]); + const controller = new AbortController(); + + await runTurn(createClaudeCodeStreamFunction(runClaude), [HI], { signal: controller.signal }); + + controller.abort(); + + const call = getOnlyCall(calls); + await assertCleanedUp(call); + await assert.rejects(fetch(getMcpUrl(call))); +}); + +test("idle timeout kills an orphaned waiting process", async (t) => { + liveProcessIdleTimeoutMs.current = 50; + t.after(() => (liveProcessIdleTimeoutMs.current = LIVE_PROCESS_IDLE_TIMEOUT_MS)); + + const readA: ToolUse = { id: "toolu_idle_a", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA]), callTool(readA), REPLY]); + + await runTurn(createClaudeCodeStreamFunction(runClaude), [HI]); + + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("two concurrent streams keep separate processes and results never cross", async (t) => { + const readOne: ToolUse = { id: "toolu_concurrent_one", name: "read", arguments: { path: "one" } }; + const readTwo: ToolUse = { id: "toolu_concurrent_two", name: "read", arguments: { path: "two" } }; + const one = createFakeRun(t, [...toToolUseMessage([readOne]), callTool(readOne), REPLY]); + const two = createFakeRun(t, [...toToolUseMessage([readTwo]), callTool(readTwo), REPLY]); + + const streamOne = createClaudeCodeStreamFunction(one.runClaude); + const streamTwo = createClaudeCodeStreamFunction(two.runClaude); + const [toolMessageOne, toolMessageTwo] = await Promise.all([runTurn(streamOne, [HI]), runTurn(streamTwo, [HI])]); + + const [replyOne, replyTwo] = await Promise.all([ + runTurn(streamOne, [HI, toolMessageOne, toToolResult(readOne, "from one")]), + runTurn(streamTwo, [HI, toolMessageTwo, toToolResult(readTwo, "from two")]), + ]); + + assert.equal(getText(replyOne), "from one"); + assert.equal(getText(replyTwo), "from two"); + assert.equal(one.calls.length, 1); + assert.equal(two.calls.length, 1); +}); diff --git a/tests/fixtures/claude-code/fake-claude.mjs b/tests/fixtures/claude-code/fake-claude.mjs new file mode 100644 index 0000000..cb64d65 --- /dev/null +++ b/tests/fixtures/claude-code/fake-claude.mjs @@ -0,0 +1,79 @@ +/** + * Stand-in for the claude CLI in tests. Reads the stream-json input from + * stdin, then replays the scenario file named by FAKE_CLAUDE_SCENARIO line by + * line. Lines with a "fake" key are directives instead of output: + * {"fake":"hold"} stay alive until killed, like the CLI waiting on a held MCP call + * {"fake":"exit","code":1,"stderr":"..."} write stderr and exit with that code + * {"fake":"call","name":"read","arguments":{...}} call that tool on the MCP bridge and wait for its result + * {"fake":"reply"} stream one assistant message whose text is every result so far, joined by " | " + * FAKE_CLAUDE_STDIN, when set, receives the stdin it read. + */ +import { readFileSync, writeFileSync } from "node:fs"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +const HOLD_INTERVAL_MS = 60_000; +const BRIDGE_SERVER_NAME = "learnharness"; + +function writeLine(line) { + return new Promise((resolve) => process.stdout.write(`${line}\n`, resolve)); +} + +function streamEvent(event) { + return JSON.stringify({ type: "stream_event", event }); +} + +async function connectBridge(mcpConfig) { + const { url, headers } = JSON.parse(mcpConfig).mcpServers[BRIDGE_SERVER_NAME]; + const client = new Client({ name: "fake-claude", version: "1.0.0" }); + + await client.connect(new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers } })); + return client; +} + +async function writeReply(text) { + await writeLine(streamEvent({ type: "message_start", message: { usage: {} } })); + await writeLine(streamEvent({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })); + await writeLine(streamEvent({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text } })); + await writeLine(streamEvent({ type: "content_block_stop", index: 0 })); + await writeLine(streamEvent({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: {} })); + await writeLine(streamEvent({ type: "message_stop" })); +} + +const mcpConfig = readFileSync(process.argv[process.argv.indexOf("--mcp-config") + 1], "utf8"); + +let input = ""; +for await (const chunk of process.stdin) input += chunk; +if (process.env.FAKE_CLAUDE_STDIN) writeFileSync(process.env.FAKE_CLAUDE_STDIN, input); + +let bridge = null; +const results = []; +const lines = readFileSync(process.env.FAKE_CLAUDE_SCENARIO, "utf8").split("\n").filter(Boolean); +for (const line of lines) { + const directive = JSON.parse(line); + + if (directive.fake === "hold") { + setInterval(() => {}, HOLD_INTERVAL_MS); + break; + } + + if (directive.fake === "exit") { + process.stderr.write(directive.stderr ?? ""); + process.exit(directive.code); + } + + if (directive.fake === "call") { + bridge ??= await connectBridge(mcpConfig); + const result = await bridge.callTool({ name: directive.name, arguments: directive.arguments }); + results.push(result.content.map((block) => block.text).join("")); + continue; + + } + if (directive.fake === "reply") { + await writeReply(results.join(" | ")); + continue; + } + + await writeLine(line); +} From 2b2cf79b9ac65567429b7108c513a8ca041699b7 Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:04:22 +0400 Subject: [PATCH 08/87] feat: register claude-code provider --- src/agent/models.ts | 3 +++ src/server/model-catalog.ts | 6 ++++-- tests/model-catalog.test.ts | 26 +++++++++++++++++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/agent/models.ts b/src/agent/models.ts index 80bc11e..034a7ea 100644 --- a/src/agent/models.ts +++ b/src/agent/models.ts @@ -12,6 +12,8 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { type ModelRole } from "../../config.ts"; import { DEFAULT_THINKING_ORDER } from "../core/settings.ts"; import packageMetadata from "../../package.json" with { type: "json" }; +import { createClaudeCodeProvider } from "./claude-code/provider.ts"; +import { streamClaudeCode } from "./claude-code/stream.ts"; /** App identity is immutable and shared by Models and every native Agent. * Agent's default Pi attribution otherwise overrides the Models identity. */ @@ -57,6 +59,7 @@ export function piModels() { if (!collection) { collection = createModels({ identity: APP_IDENTITY }); for (const provider of builtinProviders()) collection.setProvider(provider); + collection.setProvider(createClaudeCodeProvider(streamClaudeCode)); } return collection; } diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index 58b5cfc..129a754 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -34,6 +34,7 @@ import type { } from "../core/contracts/models.ts"; import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts"; import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.ts"; +import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts"; import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts"; /** The picker's client-safe model entry (contract: core/contracts/models.ts). */ @@ -130,9 +131,10 @@ export function catalogProviders(): CatalogProvider[] { /** The providers whose key is present — the picker federates exactly * these (a keyless provider's models are unreachable by design; the - * Keys page is the discovery surface, same discipline as search). */ + * Keys page is the discovery surface, same discipline as search), plus + * claude-code, which runs on the local CLI's subscription and takes no key. */ function keyedProviderIds(): Set { - return new Set(catalogProviders().filter((p) => p.keyPresent).map((p) => p.id)); + return new Set([...catalogProviders().filter((p) => p.keyPresent).map((p) => p.id), CLAUDE_CODE_PROVIDER]); } /** Project one catalog model to the client DTO. */ diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index d28f4d4..5d93a8e 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -1,6 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { + catalogContextLimits, catalogDefaultsFor, catalogProviders, catalogRolePins, @@ -22,6 +23,7 @@ import { BACKGROUND_MODEL_ROLES } from "../src/core/settings.ts"; import { resolveSettings } from "../src/core/settings-resolution.ts"; import { readSettings } from "../src/store/settings.ts"; import { effectiveSettings } from "../src/server/stores.ts"; +import { providerKeyPresent } from "../src/server/keys.ts"; // The model-control wave (2026-09-12): pi-ai owns the registry — these pins // hold our projection + validation honest against the shipped catalog, and @@ -326,4 +328,26 @@ describe("pi thinking truth (pinned against pi-ai upgrades)", () => { const { piEngineFor } = await import("../src/agent/models.ts"); assert.throws(() => piEngineFor("prober"), /no configured model for role "prober"/); }); -}); \ No newline at end of file +}); +describe("the claude-code provider (subscription CLI, no key)", () => { + it("modelCatalog() lists claude-code opus/sonnet/haiku with no keys saved", () => { + const ids = modelCatalog() + .models.filter((m) => m.provider === "claude-code") + .map((m) => m.id); + assert.deepEqual(ids, ["haiku", "opus", "sonnet"]); + }); + + it("catalogContextLimits() has claude-code:opus", () => { + assert.ok((catalogContextLimits().get("claude-code:opus") ?? 0) > 0); + }); + + it('piEngineForSpec({ provider: "claude-code", id: "sonnet" }) resolves', () => { + const engine = piEngineForSpec({ provider: "claude-code", id: "sonnet" }); + assert.equal(engine.model.provider, "claude-code"); + assert.equal(engine.model.id, "sonnet"); + }); + + it('providerKeyPresent("claude-code") is true', () => { + assert.equal(providerKeyPresent("claude-code"), true); + }); +}); From a86a4622fb24e627249ccdf224bf6caa97c3b761 Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:04:22 +0400 Subject: [PATCH 09/87] refactor: split dense lines in binary lookup and transcript replay --- src/agent/claude-code/binary.ts | 34 ++++++++++++++++++++++---- src/agent/claude-code/transcript.ts | 36 +++++++++++++++++++++++----- tests/claude-code-binary.test.ts | 5 +++- tests/claude-code-transcript.test.ts | 21 +++++++++++++--- 4 files changed, 81 insertions(+), 15 deletions(-) diff --git a/src/agent/claude-code/binary.ts b/src/agent/claude-code/binary.ts index 5e57cda..81b4a55 100644 --- a/src/agent/claude-code/binary.ts +++ b/src/agent/claude-code/binary.ts @@ -18,32 +18,51 @@ type SearchSpec = { function getWindowsSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec { const paths = path.win32; const appData = env.APPDATA ?? paths.join(homeDir, "AppData", "Roaming"); + return { paths, names: ["claude.exe", "claude.cmd"], - knownDirs: [paths.join(homeDir, ".local", "bin"), paths.join(homeDir, ".claude", "local"), env.npm_config_prefix ?? paths.join(appData, "npm")], + knownDirs: [ + paths.join(homeDir, ".local", "bin"), + paths.join(homeDir, ".claude", "local"), + env.npm_config_prefix ?? paths.join(appData, "npm"), + ], }; } function getPosixSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec { const paths = path.posix; - const npmBin = env.npm_config_prefix ? paths.join(env.npm_config_prefix, "bin") : paths.join(homeDir, ".npm-global", "bin"); + const npmBin = env.npm_config_prefix + ? paths.join(env.npm_config_prefix, "bin") + : paths.join(homeDir, ".npm-global", "bin"); + return { paths, names: ["claude"], - knownDirs: [paths.join(homeDir, ".local", "bin"), paths.join(homeDir, ".claude", "local"), npmBin, "/opt/homebrew/bin", "/usr/local/bin"], + knownDirs: [ + paths.join(homeDir, ".local", "bin"), + paths.join(homeDir, ".claude", "local"), + npmBin, + "/opt/homebrew/bin", + "/usr/local/bin", + ], }; } export function resolveClaudeBinary({ env, platform, homeDir, isExecutable }: BinaryLookup): string | null { - const { paths, names, knownDirs } = platform === "win32" ? getWindowsSearchSpec(env, homeDir) : getPosixSearchSpec(env, homeDir); + const { paths, names, knownDirs } = platform === "win32" + ? getWindowsSearchSpec(env, homeDir) + : getPosixSearchSpec(env, homeDir); + const pathDirs = (env.PATH ?? "").split(paths.delimiter).filter(Boolean); + for (const name of names) { for (const dir of [...pathDirs, ...knownDirs]) { const candidate = paths.join(dir, name); if (isExecutable(candidate)) return candidate; } } + return null; } @@ -57,5 +76,10 @@ function isExecutableFile(filePath: string): boolean { } export function getClaudeBinary(): string | null { - return resolveClaudeBinary({ env: process.env, platform: process.platform, homeDir: os.homedir(), isExecutable: isExecutableFile }); + return resolveClaudeBinary({ + env: process.env, + platform: process.platform, + homeDir: os.homedir(), + isExecutable: isExecutableFile, + }); } diff --git a/src/agent/claude-code/transcript.ts b/src/agent/claude-code/transcript.ts index 67cba3c..ecc5e5d 100644 --- a/src/agent/claude-code/transcript.ts +++ b/src/agent/claude-code/transcript.ts @@ -1,7 +1,9 @@ import type { ImageContent, Message, TextContent, Tool } from "@earendil-works/pi-ai"; import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript"; -export type ClaudeContentBlock = { type: "text"; text: string } | { type: "image"; source: { type: "base64"; media_type: string; data: string } }; +export type ClaudeContentBlock = + | { type: "text"; text: string } + | { type: "image"; source: { type: "base64"; media_type: string; data: string } }; export type ClaudeInput = { systemPrompt: string; @@ -17,8 +19,10 @@ function toImageBlock(image: ImageContent): ClaudeContentBlock { function getLabelledParts(label: string, content: string | (TextContent | ImageContent)[]): ReplayPart[] { if (typeof content === "string") return [`${label}\n${content}`]; + const text = content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("\n"); const images = content.filter((block) => block.type === "image"); + return [text ? `${label}\n${text}` : label, ...images]; } @@ -26,26 +30,33 @@ function getReplayParts(message: Message): ReplayPart[] { switch (message.role) { case "system": return []; + case "user": return getLabelledParts("[user]", message.content); + case "assistant": return message.content.flatMap((block) => { if (block.type === "text") return [`[assistant]\n${block.text}`]; if (block.type === "toolCall") return [`[tool call ${block.name}] ${JSON.stringify(block.arguments)}`]; return []; }); - case "toolResult": - return getLabelledParts(message.isError ? `[tool result ${message.toolName} error]` : `[tool result ${message.toolName}]`, message.content); + + case "toolResult": { + const label = message.isError ? `[tool result ${message.toolName} error]` : `[tool result ${message.toolName}]`; + return getLabelledParts(label, message.content); + } } } function toContentBlocks(parts: ReplayPart[]): ClaudeContentBlock[] { const blocks: ClaudeContentBlock[] = []; let pendingText: string[] = []; + const flushText = () => { if (pendingText.length > 0) blocks.push({ type: "text", text: pendingText.join("\n\n") }); pendingText = []; }; + for (const part of parts) { if (typeof part === "string") { pendingText.push(part); @@ -54,20 +65,33 @@ function toContentBlocks(parts: ReplayPart[]): ClaudeContentBlock[] { blocks.push(toImageBlock(part)); } } + flushText(); + return blocks; } function toUnlabelledBlocks(content: string | (TextContent | ImageContent)[]): ClaudeContentBlock[] { if (typeof content === "string") return [{ type: "text", text: content }]; + return content.map((block) => (block.type === "text" ? { type: "text", text: block.text } : toImageBlock(block))); } +function toUserContent(conversation: Message[]): ClaudeContentBlock[] { + const [first] = conversation; + if (conversation.length === 1 && first?.role === "user") return toUnlabelledBlocks(first.content); + + return toContentBlocks(conversation.flatMap(getReplayParts)); +} + export function toClaudeInput(messages: Message[]): ClaudeInput { const conversation = messages.filter((message) => message.role !== "system"); - const onlyMessage = conversation.length === 1 ? conversation[0] : null; - const userContent = onlyMessage?.role === "user" ? toUnlabelledBlocks(onlyMessage.content) : toContentBlocks(conversation.flatMap(getReplayParts)); - return { systemPrompt: getCurrentSystemPrompt(messages), tools: getCurrentTools(messages), userContent }; + + return { + systemPrompt: getCurrentSystemPrompt(messages), + tools: getCurrentTools(messages), + userContent: toUserContent(conversation), + }; } export function toStreamJsonLine(content: ClaudeContentBlock[]): string { diff --git a/tests/claude-code-binary.test.ts b/tests/claude-code-binary.test.ts index 00c7d34..66a716a 100644 --- a/tests/claude-code-binary.test.ts +++ b/tests/claude-code-binary.test.ts @@ -45,7 +45,10 @@ test("windows looks for claude.cmd and claude.exe", () => { assert.equal(resolveWith(["C:\\bin\\claude.cmd"], env, "win32", winHome), "C:\\bin\\claude.cmd"); assert.equal(resolveWith(["C:\\bin\\claude.exe"], env, "win32", winHome), "C:\\bin\\claude.exe"); assert.equal(resolveWith([`${winHome}\\.local\\bin\\claude.exe`], env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`); - assert.equal(resolveWith([`${winHome}\\AppData\\Roaming\\npm\\claude.cmd`], env, "win32", winHome), `${winHome}\\AppData\\Roaming\\npm\\claude.cmd`); + assert.equal( + resolveWith([`${winHome}\\AppData\\Roaming\\npm\\claude.cmd`], env, "win32", winHome), + `${winHome}\\AppData\\Roaming\\npm\\claude.cmd`, + ); assert.equal(resolveWith(["C:\\bin\\claude"], env, "win32", winHome), null); }); diff --git a/tests/claude-code-transcript.test.ts b/tests/claude-code-transcript.test.ts index 0fbec27..5e8db33 100644 --- a/tests/claude-code-transcript.test.ts +++ b/tests/claude-code-transcript.test.ts @@ -29,7 +29,14 @@ function assistant(content: AssistantMessage["content"]): AssistantMessage { api: "claude-code-cli", provider: "claude-code", model: "opus", - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, stopReason: "stop", timestamp: 0, }; @@ -96,7 +103,12 @@ test("error tool results are marked as errors in the replay", () => { toolResult("read", [{ type: "text", text: "ENOENT" }], true), user("why?"), ]; - const expected = ["[user]\nopen missing.md", '[tool call read] {"path":"missing.md"}', "[tool result read error]\nENOENT", "[user]\nwhy?"].join("\n\n"); + const expected = [ + "[user]\nopen missing.md", + '[tool call read] {"path":"missing.md"}', + "[tool result read error]\nENOENT", + "[user]\nwhy?", + ].join("\n\n"); assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]); }); @@ -142,5 +154,8 @@ test("thinking blocks are omitted from replay", () => { }); test('toStreamJsonLine emits {"type":"user","message":{"role":"user","content":[...]}}', () => { - assert.deepEqual(JSON.parse(toStreamJsonLine([{ type: "text", text: "hi" }])), { type: "user", message: { role: "user", content: [{ type: "text", text: "hi" }] } }); + assert.deepEqual(JSON.parse(toStreamJsonLine([{ type: "text", text: "hi" }])), { + type: "user", + message: { role: "user", content: [{ type: "text", text: "hi" }] }, + }); }); From 43d7ec5577154ec48afeaf908bfb636b6e742ba5 Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:16:51 +0400 Subject: [PATCH 10/87] fix: count claude-code as configured when the CLI is installed --- src/server/model-catalog.ts | 13 +++++--- tests/model-catalog.test.ts | 61 ++++++++++++++++++++++++++++++++----- 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index e833368..633a2f7 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -43,6 +43,7 @@ import type { } from "../core/contracts/models.ts"; import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts"; import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.ts"; +import { getClaudeBinary } from "../agent/claude-code/binary.ts"; import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts"; import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts"; @@ -135,15 +136,19 @@ export function catalogProviders(): CatalogProvider[] { id: p.id, envVar: p.envVar, keyPresent: keyValue(p.envVar) !== undefined, - })).concat([{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() }]).sort((a, b) => a.id.localeCompare(b.id)); + })) + .concat([ + { id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() }, + { id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: getClaudeBinary() !== null }, + ]) + .sort((a, b) => a.id.localeCompare(b.id)); } /** The providers whose key is present — the picker federates exactly * these (a keyless provider's models are unreachable by design; the - * Keys page is the discovery surface, same discipline as search), plus - * claude-code, which runs on the local CLI's subscription and takes no key. */ + * Keys page is the discovery surface, same discipline as search). */ function keyedProviderIds(): Set { - return new Set([...catalogProviders().filter((p) => p.keyPresent).map((p) => p.id), CLAUDE_CODE_PROVIDER]); + return new Set(catalogProviders().filter((p) => p.keyPresent).map((p) => p.id)); } /** Project one catalog model to the client DTO. */ diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index 5d93a8e..b3de078 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -1,3 +1,6 @@ +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 assert from "node:assert/strict"; import { @@ -329,16 +332,60 @@ describe("pi thinking truth (pinned against pi-ai upgrades)", () => { assert.throws(() => piEngineFor("prober"), /no configured model for role "prober"/); }); }); +/** Runs fn with PATH and HOME pointed at a temp dir, so the CLI lookup only + * finds a claude binary when installed is true. */ +function withClaudeInstalled(installed: boolean, fn: () => void): void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-catalog-")); + const saved = { PATH: process.env.PATH, HOME: process.env.HOME, npm_config_prefix: process.env.npm_config_prefix }; + + if (installed) fs.writeFileSync(path.join(dir, "claude"), "#!/bin/sh\n", { mode: 0o755 }); + process.env.PATH = dir; + process.env.HOME = dir; + delete process.env.npm_config_prefix; + + try { + fn(); + } finally { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + fs.rmSync(dir, { recursive: true, force: true }); + } +} + describe("the claude-code provider (subscription CLI, no key)", () => { - it("modelCatalog() lists claude-code opus/sonnet/haiku with no keys saved", () => { - const ids = modelCatalog() - .models.filter((m) => m.provider === "claude-code") - .map((m) => m.id); - assert.deepEqual(ids, ["haiku", "opus", "sonnet"]); + it("modelCatalog() lists claude-code opus/sonnet/haiku when the CLI is installed", () => { + withClaudeInstalled(true, () => { + const ids = modelCatalog() + .models.filter((m) => m.provider === "claude-code") + .map((m) => m.id); + assert.deepEqual(ids, ["haiku", "opus", "sonnet"]); + }); }); - it("catalogContextLimits() has claude-code:opus", () => { - assert.ok((catalogContextLimits().get("claude-code:opus") ?? 0) > 0); + it("catalogProviders() marks claude-code present when the CLI is installed", () => { + withClaudeInstalled(true, () => { + const row = catalogProviders().find((p) => p.id === "claude-code"); + assert.deepEqual(row, { id: "claude-code", envVar: "", keyPresent: true }); + }); + }); + + it("catalogProviders() marks claude-code absent and modelCatalog() hides it when the CLI is missing", () => { + withClaudeInstalled(false, () => { + const row = catalogProviders().find((p) => p.id === "claude-code"); + assert.equal(row?.keyPresent, false); + assert.equal( + modelCatalog().models.some((m) => m.provider === "claude-code"), + false, + ); + }); + }); + + it("catalogContextLimits() has claude-code:opus when the CLI is installed", () => { + withClaudeInstalled(true, () => { + assert.ok((catalogContextLimits().get("claude-code:opus") ?? 0) > 0); + }); }); it('piEngineForSpec({ provider: "claude-code", id: "sonnet" }) resolves', () => { From 0b77ede6ffce019712320eeff160760b1ae9e95a Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:11:31 +0400 Subject: [PATCH 11/87] test: cover tool results arriving before and after the idle timeout --- tests/claude-code-harness.test.ts | 196 ++++++++++++++++++++++++++++++ tests/claude-code-stream.test.ts | 46 +++++++ 2 files changed, 242 insertions(+) create mode 100644 tests/claude-code-harness.test.ts diff --git a/tests/claude-code-harness.test.ts b/tests/claude-code-harness.test.ts new file mode 100644 index 0000000..1dcd8c2 --- /dev/null +++ b/tests/claude-code-harness.test.ts @@ -0,0 +1,196 @@ +/** + * Claude Code provider driven through the real chat handler: a learner-facing + * question ends the turn while the CLI process waits on the held MCP call, and + * the learner's answer arrives later as that call's tool result. + * + * The runClaude seam spawns tests/fixtures/claude-code/fake-claude.mjs, one + * scenario per spawn, so the spawn count shows whether the answer continued + * the waiting process or started a fresh one with a replay. + */ +import assert from "node:assert/strict"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { once } from "node:events"; +import { mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import test, { type TestContext } from "node:test"; + +import { PATHS } from "../config.ts"; +import { LIVE_PROCESS_IDLE_TIMEOUT_MS, liveProcessIdleTimeoutMs } from "../src/agent/claude-code/live-processes.ts"; +import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; +import { createClaudeCodeStreamFunction, type RunClaude } from "../src/agent/claude-code/stream.ts"; +import type { PiEngine } from "../src/agent/models.ts"; +import { createTeacherAgent } from "../src/agent/teacher-agent.ts"; +import { danglingQuestionOf } from "../src/core/native-messages.ts"; +import { handleChatRequest } from "../src/server/chat-handler.ts"; +import { isTurnRunning } from "../src/server/turn-liveness.ts"; +import { runningTurn } from "../src/server/turn-runs.ts"; +import { chatStore } from "../src/store/chats.ts"; + +const FAKE_CLAUDE = fileURLToPath(new URL("./fixtures/claude-code/fake-claude.mjs", import.meta.url)); +const TEXT_FIXTURE = fileURLToPath(new URL("./fixtures/claude-code/text.jsonl", import.meta.url)); +const TURN_TIMEOUT_MS = 8000; + +const ASK_ID = "toolu_harness_ask"; +const ASK_INPUT = { question: "What do you already know about sockets?" }; +const ANSWER_TEXT = "Only that they carry bytes between programs"; + +type FakeRun = { runClaude: RunClaude; children: ChildProcessWithoutNullStreams[]; stdinFiles: string[] }; + +function streamEvent(event: object): string { + return JSON.stringify({ type: "stream_event", event }); +} + +function toAskScenario(): string[] { + return [ + streamEvent({ type: "message_start", message: { usage: {} } }), + streamEvent({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: ASK_ID, name: "mcp__learnharness__ask_user_question", input: {} }, + }), + streamEvent({ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify(ASK_INPUT) } }), + streamEvent({ type: "content_block_stop", index: 0 }), + streamEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }), + streamEvent({ type: "message_stop" }), + JSON.stringify({ fake: "call", name: "ask_user_question", arguments: ASK_INPUT }), + JSON.stringify({ fake: "reply" }), + ]; +} + +function createFakeRun(t: TestContext, scenarios: string[][]): FakeRun { + const dir = mkdtempSync(join(tmpdir(), "claude-code-harness-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + + const children: ChildProcessWithoutNullStreams[] = []; + const stdinFiles: string[] = []; + + const runClaude: RunClaude = (args, options) => { + const scenario = scenarios[children.length]; + assert.ok(scenario, `unexpected spawn #${children.length + 1}`); + + const scenarioFile = join(dir, `scenario-${children.length}.jsonl`); + const stdinFile = join(dir, `stdin-${children.length}.jsonl`); + writeFileSync(scenarioFile, `${scenario.join("\n")}\n`); + + const child = spawn(process.execPath, [FAKE_CLAUDE, ...args], { + cwd: options.cwd, + env: { ...options.env, FAKE_CLAUDE_SCENARIO: scenarioFile, FAKE_CLAUDE_STDIN: stdinFile }, + stdio: "pipe", + }); + t.after(() => child.kill()); + + children.push(child); + stdinFiles.push(stdinFile); + return child; + }; + + return { runClaude, children, stdinFiles }; +} + +function createClaudeCodeChat(t: TestContext): string { + const chat = chatStore.createChat("Claude Code: question answered later"); + chatStore.saveSettings(chat.id, { model: { provider: "claude-code", id: "sonnet" } }); + t.after(() => unlinkSync(join(PATHS.chats, `${chat.id}.json`))); + + return chat.id; +} + +function createAgentFactory(fakeRun: FakeRun): Parameters[1] { + const model = CLAUDE_CODE_MODELS.find((candidate) => candidate.id === "sonnet"); + assert.ok(model); + + const engine: PiEngine = { model, streamFn: createClaudeCodeStreamFunction(fakeRun.runClaude) }; + + return (options) => createTeacherAgent(undefined, { engine, messages: options.messages }); +} + +async function runChatTurn(chatId: string, input: object, agentFactory: Parameters[1]): Promise { + const request = new Request("http://test/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chatId, input }), + }); + + const ack = await handleChatRequest(request, agentFactory); + assert.equal(ack.status, 200, await ack.clone().text()); + + const handle = runningTurn(chatId); + if (handle) await new Response(handle.subscribe()).text(); + + const deadline = Date.now() + TURN_TIMEOUT_MS; + while (isTurnRunning(chatId)) { + assert.ok(Date.now() < deadline, "turn did not settle"); + await sleep(10); + } +} + +function toAnswerInput(): object { + return { + type: "answer", + toolCallId: ASK_ID, + answer: { kind: "ask", questionText: ASK_INPUT.question, dontKnow: false, answers: [], text: ANSWER_TEXT, answeredAt: new Date().toISOString() }, + }; +} + +function getLastAssistantText(chatId: string): string { + const messages = chatStore.getChat(chatId)?.messages ?? []; + const assistant = messages.findLast((message) => message.role === "assistant"); + assert.ok(assistant?.role === "assistant"); + + return assistant.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join(""); +} + +async function waitForExit(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + + await once(child, "exit", { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) }); +} + +test("a question answered before the idle timeout continues the waiting CLI process with the answer", async (t) => { + const fakeRun = createFakeRun(t, [toAskScenario()]); + const agentFactory = createAgentFactory(fakeRun); + const chatId = createClaudeCodeChat(t); + + await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, agentFactory); + + assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, ASK_ID); + const [waiting] = fakeRun.children; + assert.ok(waiting); + assert.equal(waiting.exitCode, null, "the CLI process should wait on the held question call"); + + await runChatTurn(chatId, toAnswerInput(), agentFactory); + + assert.equal(fakeRun.children.length, 1); + assert.ok(getLastAssistantText(chatId).includes(ANSWER_TEXT), getLastAssistantText(chatId)); + await waitForExit(waiting); +}); + +test("a question answered after the idle timeout starts a fresh CLI process that replays the question and answer", async (t) => { + liveProcessIdleTimeoutMs.current = 300; + t.after(() => (liveProcessIdleTimeoutMs.current = LIVE_PROCESS_IDLE_TIMEOUT_MS)); + + const fakeRun = createFakeRun(t, [toAskScenario(), readFileSync(TEXT_FIXTURE, "utf8").split("\n").filter(Boolean)]); + const agentFactory = createAgentFactory(fakeRun); + const chatId = createClaudeCodeChat(t); + + await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, agentFactory); + + const [waiting] = fakeRun.children; + assert.ok(waiting); + assert.equal(waiting.exitCode, null, "the CLI process should wait on the held question call"); + await waitForExit(waiting); + + await runChatTurn(chatId, toAnswerInput(), agentFactory); + + assert.equal(fakeRun.children.length, 2); + assert.equal(getLastAssistantText(chatId), "hello there"); + + const replay = readFileSync(fakeRun.stdinFiles[1] ?? "", "utf8"); + assert.ok(replay.includes("[tool call ask_user_question]"), replay); + assert.ok(replay.includes(ASK_INPUT.question), replay); + assert.ok(replay.includes("[tool result ask_user_question]"), replay); + assert.ok(replay.includes(ANSWER_TEXT), replay); +}); diff --git a/tests/claude-code-stream.test.ts b/tests/claude-code-stream.test.ts index 96acc52..d5e48b3 100644 --- a/tests/claude-code-stream.test.ts +++ b/tests/claude-code-stream.test.ts @@ -759,6 +759,52 @@ test("idle timeout kills an orphaned waiting process", async (t) => { await assertCleanedUp(getOnlyCall(calls)); }); +test("a tool result arriving after the idle timeout starts fresh with the call and its result in the replay", async (t) => { + liveProcessIdleTimeoutMs.current = 300; + t.after(() => (liveProcessIdleTimeoutMs.current = LIVE_PROCESS_IDLE_TIMEOUT_MS)); + + const stdinFile = join(mkdtempSync(join(tmpdir(), "claude-code-stdin-")), "stdin.jsonl"); + t.after(() => rmSync(dirname(stdinFile), { recursive: true, force: true })); + + const readA: ToolUse = { id: "toolu_late_a", name: "read", arguments: { path: "a" } }; + const waiting = createFakeRun(t, [...toToolUseMessage([readA]), callTool(readA), REPLY]); + const fresh = createFakeRun(t, readFixtureLines("text.jsonl"), { FAKE_CLAUDE_STDIN: stdinFile }); + const runClaude: RunClaude = (args, options) => (waiting.calls.length === 0 ? waiting : fresh).runClaude(args, options); + const streamFunction = createClaudeCodeStreamFunction(runClaude); + const toolMessage = await runTurn(streamFunction, [HI]); + + const waitingCall = getOnlyCall(waiting.calls); + assert.equal(waitingCall.child.exitCode, null, "the process should wait for the tool result until the idle timeout"); + await assertCleanedUp(waitingCall); + + const reply = await runTurn(streamFunction, [HI, toolMessage, toToolResult(readA, "contents of a")]); + + assert.equal(getText(reply), "hello there"); + await assertCleanedUp(getOnlyCall(fresh.calls)); + + const stdin = readFileSync(stdinFile, "utf8"); + assert.ok(stdin.includes('[tool call read] {\\"path\\":\\"a\\"}'), stdin); + assert.ok(stdin.includes("[tool result read]"), stdin); + assert.ok(stdin.includes("contents of a"), stdin); +}); + +test("a tool result arriving late but before the idle timeout still continues the waiting process", async (t) => { + liveProcessIdleTimeoutMs.current = 1000; + t.after(() => (liveProcessIdleTimeoutMs.current = LIVE_PROCESS_IDLE_TIMEOUT_MS)); + + const readA: ToolUse = { id: "toolu_slow_a", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA]), callTool(readA), REPLY]); + const streamFunction = createClaudeCodeStreamFunction(runClaude); + const toolMessage = await runTurn(streamFunction, [HI]); + + await sleep(500); + + const reply = await runTurn(streamFunction, [HI, toolMessage, toToolResult(readA, "contents of a")]); + + assert.equal(getText(reply), "contents of a"); + await assertCleanedUp(getOnlyCall(calls)); +}); + test("two concurrent streams keep separate processes and results never cross", async (t) => { const readOne: ToolUse = { id: "toolu_concurrent_one", name: "read", arguments: { path: "one" } }; const readTwo: ToolUse = { id: "toolu_concurrent_two", name: "read", arguments: { path: "two" } }; From 6112bd9ae6b6f28d0f40a426b07eabca723e602f Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:11:31 +0400 Subject: [PATCH 12/87] docs: correct idle timeout comments for question tools --- src/agent/claude-code/live-processes.ts | 5 +++-- src/agent/claude-code/stream.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/agent/claude-code/live-processes.ts b/src/agent/claude-code/live-processes.ts index f5e24b3..61f036a 100644 --- a/src/agent/claude-code/live-processes.ts +++ b/src/agent/claude-code/live-processes.ts @@ -1,7 +1,8 @@ export type LiveProcess = { toolCallIds: string[]; kill(): void }; -// pi runs a message's tools (each capped at 30s by tool-execute) and calls back well within this; -// it stays under MCP_TOOL_TIMEOUT so an orphaned process is killed before the CLI gives up on its own. +// Tools usually call back within seconds, but a question tool holds its call until the learner +// answers. Past this, the process is killed and the answer starts a fresh one with a full replay. +// It stays under MCP_TOOL_TIMEOUT so the process is killed before the CLI gives up on its own. export const LIVE_PROCESS_IDLE_TIMEOUT_MS = 10 * 60_000; /** Test seam (runner.ts's pdfModelOverride idiom). */ diff --git a/src/agent/claude-code/stream.ts b/src/agent/claude-code/stream.ts index 919561a..0f9dd4d 100644 --- a/src/agent/claude-code/stream.ts +++ b/src/agent/claude-code/stream.ts @@ -36,8 +36,9 @@ type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv }; export type RunClaude = (args: string[], options: RunClaudeOptions) => ChildProcessWithoutNullStreams | null; -// A held MCP call lasts until pi has run every tool of the message (each capped at 30s by -// tool-execute) and called back; this must outlast that so the CLI never times out first. +// A held MCP call lasts until pi calls back with the tool result, which for a question tool is +// whenever the learner answers; this must outlast the live-process idle timeout so the CLI never +// times out first. const MCP_TOOL_TIMEOUT_MS = 30 * 60_000; const PRIVATE_FILE_MODE = 0o600; From 3bb28e5f291aa74a335976f9b88efac72c189897 Mon Sep 17 00:00:00 2001 From: Bartosz Chronowki Date: Sun, 25 Oct 2026 14:33:49 +0100 Subject: [PATCH 13/87] feat(dictation): add persistent language selection in General settings --- app/api/settings/[section]/route.ts | 2 +- components/dictation-language.tsx | 143 +++++++++++ components/dictation-preference.ts | 129 ++++++++++ components/dictation-state.ts | 25 ++ components/dictation.tsx | 113 ++++----- components/settings-signal.ts | 4 +- components/settings/settings-dialog.tsx | 6 +- docs/development.md | 4 +- src/core/dictation.ts | 90 +++++++ src/core/index.ts | 1 + src/core/settings-resolution.ts | 5 + src/core/settings.ts | 16 ++ src/server/settings-body.ts | 12 + tests/dictation-flow.test.tsx | 309 ++++++++++++++++++++++++ tests/dictation-language.test.ts | 77 ++++++ tests/dictation-picker.test.tsx | 197 +++++++++++++++ tests/dictation-preference.test.ts | 93 +++++++ tests/settings-body.test.ts | 4 +- 18 files changed, 1158 insertions(+), 72 deletions(-) create mode 100644 components/dictation-language.tsx create mode 100644 components/dictation-preference.ts create mode 100644 components/dictation-state.ts create mode 100644 src/core/dictation.ts create mode 100644 tests/dictation-flow.test.tsx create mode 100644 tests/dictation-language.test.ts create mode 100644 tests/dictation-picker.test.tsx create mode 100644 tests/dictation-preference.test.ts diff --git a/app/api/settings/[section]/route.ts b/app/api/settings/[section]/route.ts index 616db9f..7e9198d 100644 --- a/app/api/settings/[section]/route.ts +++ b/app/api/settings/[section]/route.ts @@ -5,7 +5,7 @@ * discipline, same as the per-chat and defaults routes); sections are * independent, so partial writes can never clobber each other. * - * section ∈ general | chat | agent | appearance | graph | compaction + * section ∈ general | chat | agent | appearance | graph | compaction | models | dictation * Body: the complete desired section object. Parse + validate strictly * (server/settings-body.ts): a bad shape/type is a 400 carrying the * supported list where one exists. The chat and models sections diff --git a/components/dictation-language.tsx b/components/dictation-language.tsx new file mode 100644 index 0000000..ccb85cd --- /dev/null +++ b/components/dictation-language.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { Command } from "cmdk"; +import { CheckIcon, ChevronDownIcon } from "lucide-react"; +import { + DICTATION_LANGUAGES, fuzzyScore, type DictationLanguage, +} from "@/src/core"; +import { MenuShell } from "@/components/menu-shell"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Hint } from "@/components/ui/hint"; +import { SettingsRow } from "@/components/settings/controls"; +import { useDictationActive } from "@/components/dictation-state"; +import { useDictationLanguage } from "@/components/dictation-preference"; + +type LanguagePreference = ReturnType; + +export function DictationLanguageSetting() { + const preference = useDictationLanguage(); + const active = useDictationActive(); + return + + ; +} + +function DictationLanguagePicker({ preference, disabled }: { + preference: LanguagePreference; + disabled: boolean; +}) { + const { language, saving, error, load, save } = preference; + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const triggerRef = useRef(null); + const label = DICTATION_LANGUAGES.find((item) => item.code === language)?.label ?? "—"; + // Own ordering so clearing a search restores the catalog's order. + // cmdk handles keyboard/ARIA, but its DOM sorting retains old ranks. + const results = query.trim() ? DICTATION_LANGUAGES + .map((item) => ({ item, score: fuzzyScore(query, item.code, item.label) })) + .filter((item) => item.score !== null) + .sort((a, b) => b.score! - a.score!) + .map(({ item }) => item) : DICTATION_LANGUAGES; + + useEffect(() => { if (disabled) setOpen(false); }, [disabled]); + useEffect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Element; + if (target instanceof Element && target.closest('[data-testid="dictation-language-menu"]')) return; + if (!triggerRef.current?.contains(target)) setOpen(false); + }; + document.addEventListener("pointerdown", onPointerDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + }; + }, [open]); + const closeOnEscape = (event: ReactKeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + triggerRef.current?.focus(); + }; + const pick = async (code: DictationLanguage) => { + if (disabled || saving || language === undefined) return; + if (await save(code)) { + setOpen(false); + triggerRef.current?.focus(); + } + }; + + return <> + + + + {open && !disabled && + {error &&
{error}
} + { + closeOnEscape(event); + // Keep menu navigation out of the composer's submit/shortcut + // handlers. cmdk owns selection, scrolling and ARIA state. + if (["ArrowDown", "ArrowUp", "Enter", "Home", "End"].includes(event.key)) event.stopPropagation(); + }} + > + + + + + No languages match. + {results.map((item) => { void pick(item.code); }} + asChild + > + + )} + + +
} + ; +} diff --git a/components/dictation-preference.ts b/components/dictation-preference.ts new file mode 100644 index 0000000..8151a9b --- /dev/null +++ b/components/dictation-preference.ts @@ -0,0 +1,129 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { + FACTORY_SETTINGS, isDictationLanguage, sanitizeUserSettings, + type DictationLanguage, type UserSettings, +} from "@/src/core"; +import { notifySettingsSaved, onSettingsSaved } from "@/components/settings-signal"; + +const INITIAL = { language: undefined as DictationLanguage | undefined, saving: false, error: null as string | null }; + +/** One confirmed preference and one write in flight, shared by every surface. + * The factory keeps transport tests isolated without a second persistence path. + */ +export function createDictationLanguageStore(request: typeof fetch, saved: () => void = () => {}) { + let snapshot = INITIAL; + let reading: Promise | null = null; + let writing: Promise | null = null; + let revision = 0; + let needsRead = true; + const listeners = new Set<() => void>(); + const publish = (patch: Partial) => { + snapshot = { ...snapshot, ...patch }; + for (const listener of listeners) listener(); + }; + + function load(refresh = false): Promise { + if (writing) return getLanguage(); + if (reading && !refresh) return reading; + const version = ++revision; + needsRead = true; + reading = Promise.resolve().then(async () => { + try { + const res = await request("/api/settings"); + if (!res.ok) throw new Error(); + const body = await res.json() as { settings: UserSettings | null }; + if (!("settings" in body)) throw new Error(); + const record = sanitizeUserSettings(body.settings); + if (body.settings !== null && record === null) throw new Error(); + if (version !== revision) return getLanguage(); + const language = record?.dictation?.language ?? FACTORY_SETTINGS.dictation.language; + needsRead = false; + publish({ language, error: null }); + return language; + } catch { + if (version !== revision) return getLanguage(); + const message = "Could not load dictation language. Try again."; + publish({ error: message }); + throw new Error(message); + } finally { + if (version === revision) reading = null; + } + }); + return reading; + } + + function save(language: DictationLanguage): Promise { + // A different selection must never inherit the previous write's success. + if (writing || snapshot.language === undefined) return Promise.resolve(false); + revision++; + reading = null; + writing = Promise.resolve().then(async () => { + try { + const res = await request("/api/settings/dictation", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ language }), + }); + if (!res.ok) throw new Error(); + const body = await res.json() as { settings: UserSettings }; + const confirmed = body.settings?.dictation?.language; + if (!isDictationLanguage(confirmed)) throw new Error(); + needsRead = false; + publish({ language: confirmed, error: null }); + saved(); + return true; + } catch { + publish({ error: "Could not save dictation language. Try again." }); + return false; + } finally { + writing = null; + publish({ saving: false }); + } + }); + publish({ saving: true, error: null }); + return writing; + } + + async function getLanguage(): Promise { + for (;;) { + if (writing) await writing; + else if (reading) await reading; + else if (needsRead || snapshot.language === undefined) await load(); + else return snapshot.language; + } + } + + return { + load, save, getLanguage, + getSnapshot: () => snapshot, + getServerSnapshot: () => INITIAL, + subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; }, + }; +} + +let announcingSave = false; +const store = createDictationLanguageStore((...args) => fetch(...args), () => { + // All our readers already received the confirmed PUT response. Keep the + // repo's notification contract without depending on a redundant GET. + announcingSave = true; + try { notifySettingsSaved("dictation"); } finally { announcingSave = false; } +}); +let readers = 0; +let stopSignals: (() => void) | undefined; + +function subscribe(listener: () => void) { + const off = store.subscribe(listener); + if (readers++ === 0) { + stopSignals = onSettingsSaved((section) => { + if (section === "dictation" && !announcingSave) void store.load(true).catch(() => {}); + }); + if (!store.getSnapshot().saving) void store.load(true).catch(() => {}); + } + return () => { off(); if (--readers === 0) { stopSignals?.(); stopSignals = undefined; } }; +} + +export function useDictationLanguage() { + const snapshot = useSyncExternalStore(subscribe, store.getSnapshot, store.getServerSnapshot); + return { ...snapshot, load: store.load, save: store.save, getLanguage: store.getLanguage }; +} diff --git a/components/dictation-state.ts b/components/dictation-state.ts new file mode 100644 index 0000000..8f290b1 --- /dev/null +++ b/components/dictation-state.ts @@ -0,0 +1,25 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +// The keyboard chain needs a synchronous answer; settings also subscribe +// so the language control locks from connecting until cleanup completes. +let active = false; +const listeners = new Set<() => void>(); + +export function isDictationActive(): boolean { return active; } + +export function setDictationActive(next: boolean): void { + if (active === next) return; + active = next; + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +export function useDictationActive(): boolean { + return useSyncExternalStore(subscribe, isDictationActive, () => false); +} diff --git a/components/dictation.tsx b/components/dictation.tsx index ee34b77..fdb78c7 100644 --- a/components/dictation.tsx +++ b/components/dictation.tsx @@ -4,6 +4,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Loader2Icon, MicIcon } from "lucide-react"; import { useSettingsDialog } from "@/components/settings/settings-dialog"; import { Hint } from "@/components/ui/hint"; +import { Button } from "@/components/ui/button"; +import { deepgramListenUrl, type DictationLanguage } from "@/src/core"; +import { useDictationLanguage } from "@/components/dictation-preference"; +import { setDictationActive } from "@/components/dictation-state"; +export { isDictationActive } from "@/components/dictation-state"; /** * Voice dictation — the cloud engine (STT wave 1, 2026-09-17). @@ -86,15 +91,7 @@ import { Hint } from "@/components/ui/hint"; * endpointing=300, no interims. Silence streams too (the worklet never * pauses), so the socket never idles — no KeepAlive needed. */ -const DG_URL = - "wss://api.deepgram.com/v1/listen" + - "?model=nova-3" + - "&encoding=linear16" + - "&sample_rate=16000" + - "&channels=1" + - "&smart_format=true" + - "&punctuate=true" + - "&endpointing=300"; +// URL construction lives in the browser-safe core; only language varies. /** Meter tuning — the owner's tested values, see the file header. */ const METER_FLOOR_DB = -50; @@ -204,16 +201,6 @@ export function rmsToMeter(rms: number): number { */ let questionTarget: ((text: string) => void) | null = null; -/* Is a dictation run live on this page? The Esc chain (keyboard wave A, - * components/keyboard-shortcuts.tsx) needs a SYNCHRONOUS answer — the - * stop handler can't report back through a CustomEvent. Set at start(), - * cleared in cleanup() (every terminal path runs through cleanup). */ -let dictationActiveFlag = false; - -export function isDictationActive(): boolean { - return dictationActiveFlag; -} - /* The finisher registry: submit paths FAR from the hook's owner (the * pending question's dock) need finish() without a prop bridge. Same * module-level pattern as questionTarget — one window, one instance, @@ -285,6 +272,7 @@ export type Dictation = { * question waits. */ export function useDictation({ append }: { append: (text: string) => void }): Dictation { + const { getLanguage } = useDictationLanguage(); const [phase, setPhase] = useState("idle"); const [error, setError] = useState(null); /** Latest RMS from the worklet — the meter samples this (dictate pattern). */ @@ -327,9 +315,9 @@ export function useDictation({ append }: { append: (text: string) => void }): Di }, []); /** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */ - const cleanup = useCallback(() => { + const cleanup = useCallback((warmNext = true) => { genRef.current++; - dictationActiveFlag = false; + setDictationActive(false); flush(); // the deferred transcript appears when the run ends (owner round 2) if (safetyRef.current) { clearTimeout(safetyRef.current); @@ -357,16 +345,16 @@ export function useDictation({ append }: { append: (text: string) => void }): Di for (const w of waitersRef.current.splice(0)) w(); // Pre-mint for the next run (mitigation D) — best-effort; a keyless // page just doesn't warm. - warmTokens.refresh(); + if (warmNext) warmTokens.refresh(); }, []); /** Open the Deepgram socket and wire its messages. `gen` guards staleness. */ const openSocket = useCallback( - (gen: number, token: string) => { + (gen: number, token: string, language: DictationLanguage) => { // Minted JWTs authenticate via the "bearer" subprotocol — ["token", // jwt] is rejected at the handshake (verified live; "token" is for // raw API keys only, as in the owner's pi dictate extension). - const ws = new WebSocket(DG_URL, ["bearer", token]); + const ws = new WebSocket(deepgramListenUrl(language), ["bearer", token]); wsRef.current = ws; ws.addEventListener("open", () => { @@ -408,7 +396,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di reconnectsRef.current++; mintFreshToken() // always FRESH — a stale warm token is the likely culprit .then((t) => { - if (gen === genRef.current) openSocket(gen, t); + if (gen === genRef.current) openSocket(gen, t, language); }) .catch(() => { if (gen === genRef.current) { @@ -426,13 +414,23 @@ export function useDictation({ append }: { append: (text: string) => void }): Di ); const start = useCallback(async () => { - dictationActiveFlag = true; + setDictationActive(true); setError(null); setPhase("connecting"); reconnectsRef.current = 0; bufferRef.current = []; // fresh run, fresh buffer (round 2) pendingRef.current = []; // fresh run, fresh queue (mitigation A) const gen = ++genRef.current; + let language: DictationLanguage; + try { + language = await getLanguage(); + } catch (e) { + if (gen !== genRef.current) return; + setError(e instanceof Error ? e.message : "Dictation language unavailable"); + cleanup(); + return; + } + if (gen !== genRef.current) return; // Mitigation D: the warm token (pre-minted at mount / after the last // run) resolves ~instantly; a cold/keyless mint surfaces below. The @@ -509,8 +507,8 @@ export function useDictation({ append }: { append: (text: string) => void }): Di if (gen !== genRef.current) return; // Mitigation B: the handshake fires the moment the graph is live, // overlapping any remaining token wait — mic-gated, never before it. - openSocket(gen, token); - }, [openSocket, cleanup]); + openSocket(gen, token, language); + }, [openSocket, cleanup, getLanguage]); const stop = useCallback(() => { const gen = genRef.current; @@ -586,7 +584,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di // attached. useEffect(() => { warmTokens.refresh(); // pre-mint at mount (mitigation D) — silent if keyless - }, []); + return () => { + // Navigation cancels capture: invalidate every pending await before + // releasing resources, and never append into a different page's input. + bufferRef.current = []; + cleanup(false); + }; + }, [cleanup]); useEffect(() => { window.addEventListener("lh:kbd-toggle-dictation", toggle); return () => window.removeEventListener("lh:kbd-toggle-dictation", toggle); @@ -630,7 +634,6 @@ export function DictationBar({ dictation }: { dictation: Dictation }) { * error keeps the old behavior (click = retry, which clears). */ const keyless = error === DICTATION_KEYLESS; const [levels, setLevels] = useState(() => new Array(METER_CELLS).fill(0)); - const recording = phase === "recording" || phase === "stopping"; /** Adaptive cell count (owner round 2): derived from the meter's * measured width — narrow rows show fewer bars instead of * overflowing under neighbors. */ @@ -673,17 +676,11 @@ export function DictationBar({ dictation }: { dictation: Dictation }) { if (!active) { return ( - - + ); } return (
- - + - {/* Fixed width while recording — nothing ever appends text beside it - (no interims, owner round 1). Cells carry no minimum width; the - COUNT adapts to the measured row space (owner round 2). */}
{levels.slice(-cellCount).map((v, i) => ( void): v const saved = useRef(handler); saved.current = handler; useEffect(() => onSettingsSaved((section) => saved.current(section)), []); -} \ No newline at end of file +} diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx index 1142bdf..e79ab61 100644 --- a/components/settings/settings-dialog.tsx +++ b/components/settings/settings-dialog.tsx @@ -40,7 +40,8 @@ import { type UserSettings, } from "@/src/core"; import { useAppearance } from "./appearance.tsx"; -import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx"; +import { SettingsGroup, SettingsSection, SettingsRow, Toggle } from "./controls.tsx"; +import { DictationLanguageSetting } from "@/components/dictation-language"; import { AgentSection } from "./agent-section.tsx"; import type { ModelsSettings } from "@/src/core"; import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "@/components/menu-shell"; @@ -375,6 +376,9 @@ export function SettingsDialogBody({ } /> + + + )} {active === "keys" && ( diff --git a/docs/development.md b/docs/development.md index 062a72e..14c1a71 100644 --- a/docs/development.md +++ b/docs/development.md @@ -165,7 +165,7 @@ pricing-generation step: Pi AI's catalog and public calculator are the authority **Practice (problem sets).** A node selection resolves to the STORED reasoning chains it fully contains (a chain rides iff every member is selected; unchained nodes ride as singletons); the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — a full-bleed sortable table of every reasoning chain, grouped by birth cluster under the shared top bar, selecting chips, chains, or whole clusters, whose floating selection bar shows the exact `N nodes → M chains` the agents will receive — handing a free-form node selection to the same dialog — and carries the fenced Delete action, making it the graph's management view (whole-cluster cleanup through the same full-death confirm dialog). A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot. -**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. +**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. Settings → General → Dictation → Language offers Nova-3's documented monolingual languages/locales or `language=multi` for automatic multilingual transcription. The app-wide `dictation.language` preference persists through the existing settings record and `PUT /api/settings/dictation`; absent preferences resolve to English (`en`). The settings control locks from connecting through stopping, and reconnects keep the run's original language. A shared preference controller publishes confirmed PUT responses to every reader immediately; subsequent read failures cannot revert a confirmed choice. External settings notifications invalidate the cache, and capture waits for a successful refresh. Navigating away cancels the run, invalidates pending startup/reconnect work and releases its microphone/socket without appending into another page's input. All other streaming parameters remain unchanged. The picker composes the shared Button, Input, Hint and MenuShell with the existing `cmdk` dependency for accessible list navigation, using the app's fuzzy-search scoring. ## Testing @@ -185,4 +185,4 @@ The teaching methodology comes from an Obsidian-based POC (its `knowledge graph Nothing under `data/` is ever tracked by git. Chats, graph ledgers, jobs, positions, and the settings records are plain files (JSON/JSONL) — inspect them freely; they are the canonical records. The desktop app relocates the whole directory via `LH_DATA_DIR`. -**Schema stamps (2026-10-10):** chat records carry `version` (stamped at the store's write choke point; absence reads as v1 — lazy migration, no mass rewrite), and the graph family (ledgers, sources, render-settings) shares one sidecar `graph/format.json`, created on the family's first write. `positions.json` is deliberately unstamped (rebuildable). A record stamped NEWER than the running build is refused on read — honest refusal for the downgrade scenario: a read-modify-write store never rewrites what it doesn't understand. \ No newline at end of file +**Schema stamps (2026-10-10):** chat records carry `version` (stamped at the store's write choke point; absence reads as v1 — lazy migration, no mass rewrite), and the graph family (ledgers, sources, render-settings) shares one sidecar `graph/format.json`, created on the family's first write. `positions.json` is deliberately unstamped (rebuildable). A record stamped NEWER than the running build is refused on read — honest refusal for the downgrade scenario: a read-modify-write store never rewrites what it doesn't understand. diff --git a/src/core/dictation.ts b/src/core/dictation.ts new file mode 100644 index 0000000..45d6826 --- /dev/null +++ b/src/core/dictation.ts @@ -0,0 +1,90 @@ +/** Nova-3 monolingual language codes, including documented locale aliases. + * Source: https://developers.deepgram.com/docs/models-languages-overview + * Multilingual is a separate mode; its supported language set differs. + */ +const NOVA_3_LANGUAGES = [ + ["Afrikaans", ["af", "af-ZA"]], + ["Arabic", ["ar", "ar-AE", "ar-SA", "ar-QA", "ar-KW", "ar-SY", "ar-LB", "ar-PS", "ar-JO", "ar-EG", "ar-SD", "ar-TD", "ar-MA", "ar-DZ", "ar-TN", "ar-IQ", "ar-IR"]], + ["Armenian", ["hy"]], + ["Assamese", ["as", "as-IN"]], + ["Belarusian", ["be"]], + ["Bengali", ["bn"]], + ["Bosnian", ["bs"]], + ["Bulgarian", ["bg"]], + ["Catalan", ["ca"]], + ["Chinese (Cantonese, Traditional)", ["zh-HK"]], + ["Chinese (Mandarin, Simplified)", ["zh", "zh-CN", "zh-Hans"]], + ["Chinese (Mandarin, Traditional)", ["zh-TW", "zh-Hant"]], + ["Croatian", ["hr"]], + ["Czech", ["cs", "cs-CZ"]], + ["Danish", ["da", "da-DK"]], + ["Dutch", ["nl"]], + ["English", ["en", "en-US", "en-AU", "en-GB", "en-IN", "en-NZ"]], + ["Estonian", ["et"]], + ["Finnish", ["fi"]], + ["Flemish", ["nl-BE"]], + ["French", ["fr", "fr-CA"]], + ["Georgian", ["ka", "ka-GE"]], + ["German", ["de"]], + ["German (Switzerland)", ["de-CH"]], + ["Greek", ["el"]], + ["Gujarati", ["gu", "gu-IN"]], + ["Hebrew", ["he"]], + ["Hindi", ["hi"]], + ["Hungarian", ["hu"]], + ["Indonesian", ["id"]], + ["Italian", ["it"]], + ["Japanese", ["ja"]], + ["Kannada", ["kn"]], + ["Kazakh", ["kk", "kk-KZ"]], + ["Korean", ["ko", "ko-KR"]], + ["Latvian", ["lv"]], + ["Lithuanian", ["lt"]], + ["Macedonian", ["mk"]], + ["Malay", ["ms"]], + ["Marathi", ["mr"]], + ["Mongolian", ["mn"]], + ["Nepali", ["ne"]], + ["Norwegian", ["no"]], + ["Pashto", ["ps", "ps-AF"]], + ["Persian", ["fa"]], + ["Polish", ["pl"]], + ["Portuguese", ["pt", "pt-BR", "pt-PT"]], + ["Punjabi", ["pa", "pa-IN"]], + ["Romanian", ["ro"]], + ["Russian", ["ru"]], + ["Serbian", ["sr"]], + ["Slovak", ["sk"]], + ["Slovenian", ["sl"]], + ["Spanish", ["es", "es-419"]], + ["Swedish", ["sv", "sv-SE"]], + ["Tagalog", ["tl"]], + ["Tamil", ["ta"]], + ["Telugu", ["te"]], + ["Thai", ["th", "th-TH"]], + ["Turkish", ["tr", "tr-TR"]], + ["Ukrainian", ["uk"]], + ["Urdu", ["ur"]], + ["Vietnamese", ["vi"]], +] as const; + +export type DictationLanguage = "multi" | (typeof NOVA_3_LANGUAGES)[number][1][number]; + +export const DICTATION_LANGUAGES: readonly { code: DictationLanguage; label: string }[] = [ + { code: "multi", label: "Multilingual" }, + ...NOVA_3_LANGUAGES.flatMap(([name, codes]) => + codes.map((code, i) => ({ code, label: i === 0 ? name : `${name} (${code})` })), + ).sort((a, b) => a.label.localeCompare(b.label, "en")), +]; + +export function isDictationLanguage(value: unknown): value is DictationLanguage { + return DICTATION_LANGUAGES.some(({ code }) => code === value); +} + +/** Only language varies. Keep the owner's tuned streaming parameters. */ +export function deepgramListenUrl(language: DictationLanguage): string { + return "wss://api.deepgram.com/v1/listen" + + "?model=nova-3&encoding=linear16&sample_rate=16000&channels=1" + + "&smart_format=true&punctuate=true&endpointing=300" + + `&language=${encodeURIComponent(language)}`; +} diff --git a/src/core/index.ts b/src/core/index.ts index b4106d7..d869aa8 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -58,6 +58,7 @@ export * from "./fuzzy.ts"; // the user-settings record's shape/sanitizer (pure) and its fully-resolved // view. Browser-safe — the Wave-4 panel imports these directly. export * from "./settings.ts"; +export * from "./dictation.ts"; // graph-deletion.ts (wave 6): the whole module is PURE but storage-port // driven — the deletion RUNS server-side only. The shell needs exactly one // two type-only things from it: the ChatDeleteMode union (the delete diff --git a/src/core/settings-resolution.ts b/src/core/settings-resolution.ts index ce4b6aa..1a1cbf9 100644 --- a/src/core/settings-resolution.ts +++ b/src/core/settings-resolution.ts @@ -35,6 +35,7 @@ import { type BackgroundModelRole, type FactorySettings, type GeneralSettings, + type DictationSettings, type CompactionSettings, type GraphSettings, type ModelSlot, @@ -69,6 +70,7 @@ export type EffectiveAppearance = FactorySettings["appearance"]; /** The concrete view: every knob resolved, no absent fields. */ export interface EffectiveSettings { general: Required; + dictation: Required; agent: { models: Record; /** Delta map as stored — consumers resolve absent key → enabled. */ @@ -176,6 +178,9 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings general: { autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate, }, + dictation: { + language: record?.dictation?.language ?? FACTORY_SETTINGS.dictation.language, + }, agent: { models, tools: agent.tools ?? {}, diff --git a/src/core/settings.ts b/src/core/settings.ts index 4daece8..2216292 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -32,6 +32,7 @@ import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts"; import type { ModelRole } from "../../config.ts"; import type { ChatSettings } from "./contracts/chat.ts"; import type { SearchEngineId } from "./search.ts"; +import { isDictationLanguage, type DictationLanguage } from "./dictation.ts"; /** The schema version this code understands. Bump ONLY for breaking shape * changes (a field renamed/meaning changed); pure additions never bump. */ @@ -235,6 +236,11 @@ export interface GeneralSettings { autoUpdate?: boolean; } +/** One app-wide dictation preference, shared by every composer. */ +export interface DictationSettings { + language?: DictationLanguage; +} + /** Agent settings — everything about the non-conversation machinery. */ export interface AgentSettings { /** Background-role model/thinking overrides (absent role = config pin). */ @@ -361,6 +367,7 @@ export interface CompactionSettings { export interface UserSettings { version: number; general?: GeneralSettings; + dictation?: DictationSettings; agent?: AgentSettings; /** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */ models?: ModelsSettings; @@ -378,6 +385,7 @@ export interface UserSettings { export interface FactorySettings { version: number; general: Required; + dictation: Required; appearance: { mode: "dark" | "light"; background: string | null; @@ -397,6 +405,7 @@ export interface FactorySettings { export const FACTORY_SETTINGS: FactorySettings = { version: CURRENT_SETTINGS_VERSION, general: { autoUpdate: true }, + dictation: { language: "en" }, appearance: { mode: "dark", background: null, // the dark default (#141414) lives in the mode's derivation curve @@ -615,6 +624,13 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null { if (r.general !== null && typeof r.general === "object" && !Array.isArray(r.general)) { out.general = sanitizeGeneral(r.general as Record); } + if (r.dictation !== undefined) { + const raw = r.dictation; + out.dictation = raw !== null && typeof raw === "object" && !Array.isArray(raw) && + isDictationLanguage((raw as Record).language) + ? { language: (raw as DictationSettings).language } + : {}; + } if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) { out.agent = sanitizeAgent(r.agent as Record); } diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index 2cf1050..9b1a173 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -19,12 +19,14 @@ import type { BackgroundModelRole, CompactionSettings, GeneralSettings, + DictationSettings, GraphSettings, ModelsSettings, RoleModelOverride, } from "../core/settings.ts"; import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts"; import { WRITABLE_TOOL_KEYS } from "../core/pending.ts"; +import { isDictationLanguage } from "../core/dictation.ts"; /** Parsed-or-rejected — `ok: false` carries the 400's error message. */ export type SettingsBodyResult = @@ -375,6 +377,15 @@ export function parseCompactionSettingsBody(body: unknown): SettingsBodyResult { + const raw = asRecord(body); + if (!raw) return { ok: false, error: "Invalid JSON body" }; + if (raw.language === undefined) return { ok: true, settings: {} }; + if (!isDictationLanguage(raw.language)) return { ok: false, error: "Unsupported dictation language" }; + return { ok: true, settings: { language: raw.language } }; +} + /** The section→parser map for PUT /api/settings/[section] (the route also * carries the special prompts sub-resource). */ export const SETTINGS_SECTIONS = { @@ -385,6 +396,7 @@ export const SETTINGS_SECTIONS = { graph: parseGraphSettingsBody, compaction: parseCompactionSettingsBody, models: parseModelsSettingsBody, + dictation: parseDictationSettingsBody, } as const; export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS; diff --git a/tests/dictation-flow.test.tsx b/tests/dictation-flow.test.tsx new file mode 100644 index 0000000..cd6d20f --- /dev/null +++ b/tests/dictation-flow.test.tsx @@ -0,0 +1,309 @@ +import React, { act } from "react"; +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { parseHTML } from "linkedom"; +import { useDictation, type Dictation } from "../components/dictation.tsx"; +import { notifySettingsSaved } from "../components/settings-signal.ts"; +import { useDictationLanguage } from "../components/dictation-preference.ts"; +import { isDictationActive, useDictationActive } from "../components/dictation-state.ts"; +import type { UserSettings } from "../src/core/index.ts"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +class Socket extends EventTarget { + static CONNECTING = 0; + static OPEN = 1; + static all: Socket[] = []; + readyState = Socket.CONNECTING; + sent: unknown[] = []; + constructor(readonly url: string, readonly protocols: string[]) { super(); Socket.all.push(this); } + get language() { return new URL(this.url).searchParams.get("language"); } + send(data: unknown) { this.sent.push(data); } + close() { this.readyState = 3; } + open() { this.readyState = Socket.OPEN; this.dispatchEvent(new Event("open")); } + end(code = 1000) { this.close(); this.dispatchEvent(Object.assign(new Event("close"), { code })); } + final(text: string) { + this.dispatchEvent(Object.assign(new Event("message"), { + data: JSON.stringify({ type: "Results", is_final: true, channel: { alternatives: [{ transcript: text }] } }), + })); + } +} + +class AudioGraph { + state = "running"; + destination = {}; + audioWorklet = { addModule: async () => {} }; + createMediaStreamSource() { return { connect() {}, disconnect() {} }; } +} +class Worklet { port = {}; connect() {} disconnect() {} } + +const globals = globalThis as Record; +let restore: (() => void) | undefined; +let dispose: (() => Promise) | undefined; + +async function fixture(language: "pl" | "en" = "pl", delayedRead?: Promise) { + const dom = parseHTML("
"); + const state = { + record: { version: 1, dictation: { language } } as UserSettings, + nextRead: delayedRead, + nextWrite: undefined as Promise | undefined, + nextMic: undefined as Promise<{ getTracks: () => { stop: () => void }[] }> | undefined, + readFails: false, + active: false, + captures: 0, + stops: 0, + appends: [] as string[], + }; + Socket.all = []; + const previous = new Map(); + for (const [key, value] of Object.entries({ + React, window: dom.window, document: dom.document, CustomEvent: dom.window.CustomEvent, + navigator: { userAgent: "", platform: "", mediaDevices: { getUserMedia: async () => { + state.captures++; + if (state.nextMic) return state.nextMic; + return { getTracks: () => [{ stop() { state.stops++; } }] }; + } } }, + WebSocket: Socket, AudioContext: AudioGraph, AudioWorkletNode: Worklet, + IS_REACT_ACT_ENVIRONMENT: true, + fetch: async (url: string, init?: RequestInit) => { + if (url === "/api/stt/token") return Response.json({ token: "test-token" }); + if (url === "/api/settings") { + if (state.nextRead) { const response = state.nextRead; state.nextRead = undefined; return response; } + return state.readFails ? Response.json({}, { status: 500 }) : Response.json({ settings: state.record }); + } + assert.equal(url, "/api/settings/dictation"); + assert.equal(init?.method, "PUT"); + if (state.nextWrite) { const response = state.nextWrite; state.nextWrite = undefined; return response; } + state.record = { ...state.record, dictation: JSON.parse(init.body as string) }; + return Response.json({ settings: state.record }); + }, + })) { + previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); + } + restore = () => { + for (const [key, descriptor] of previous) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete globals[key]; + } + }; + const { createRoot } = await import("react-dom/client"); + let latest!: Dictation; + let setting!: ReturnType; + function Harness() { + latest = useDictation({ append: (text) => state.appends.push(text) }); + setting = useDictationLanguage(); + state.active = useDictationActive(); + return null; + } + let root = createRoot(document.getElementById("root")!); + const mount = async () => { await act(async () => { root.render(); }); }; + dispose = async () => { await act(async () => { root.unmount(); }); }; + return { + state, + get dictation() { return latest; }, + get setting() { return setting; }, + mount, + unmount: () => dispose!(), + remount: async () => { + await dispose!(); + root = createRoot(document.getElementById("root")!); + await mount(); + }, + }; +} + +afterEach(async () => { await dispose?.(); restore?.(); dispose = undefined; restore = undefined; }); + +test("waits for the saved language; reconnect preserves it and finals still flush exactly once", async () => { + const read = deferred(); + const f = await fixture("pl", read.promise); + await f.mount(); + await act(async () => { f.dictation.toggle(); }); + assert.equal(f.dictation.phase, "connecting"); + assert.equal(f.state.active, true, "the settings row locks while connecting"); + assert.equal(f.state.captures, 0); + assert.equal(Socket.all.length, 0, "must not open a default-English socket before settings arrive"); + await act(async () => { read.resolve(Response.json({ settings: f.state.record })); }); + const first = Socket.all[0]!; + assert.equal(first.language, "pl"); + await act(async () => { first.open(); first.final("Cześć"); }); + assert.deepEqual(f.state.appends, []); + // A preference refresh during capture affects the next run only. + f.state.record.dictation = { language: "multi" }; + await act(async () => { notifySettingsSaved("dictation"); }); + await act(async () => { first.end(1006); }); + const second = Socket.all[1]!; + assert.equal(second.language, "pl"); + assert.deepEqual(second.protocols, ["bearer", "test-token"]); + await act(async () => { second.open(); second.final("świecie"); }); + await act(async () => { f.dictation.stop(); }); + assert.equal(f.dictation.phase, "stopping"); + assert.equal(f.state.active, true, "the settings row remains locked while stopping"); + assert.equal(second.sent.at(-1), JSON.stringify({ type: "CloseStream" })); + await act(async () => { second.end(); }); + assert.deepEqual(f.state.appends, ["Cześć świecie"]); + assert.equal(f.dictation.phase, "idle"); + assert.equal(f.state.active, false); + await act(async () => { f.dictation.toggle(); }); + assert.equal(Socket.all[2]!.language, "multi"); +}); + +test("a failed save keeps the previous language, including a shortcut pressed while saving", async () => { + const f = await fixture(); + await f.mount(); + const write = deferred(); + f.state.nextWrite = write.promise; + let saved!: Promise; + await act(async () => { saved = f.setting.save("multi"); }); + assert.equal(f.setting.saving, true); + await act(async () => { f.dictation.toggle(); }); + assert.equal(f.state.captures, 0); + await act(async () => { write.resolve(Response.json({}, { status: 500 })); await saved; }); + assert.equal(await saved, false); + assert.equal(f.setting.language, "pl"); + assert.match(f.setting.error!, /Could not save/); + assert.equal(Socket.all[0]!.language, "pl"); +}); + +test("successful choices survive unmount/remount and failed reads never start capture", async () => { + const f = await fixture(); + await f.mount(); + await act(async () => { assert.equal(await f.setting.save("multi"), true); }); + await f.remount(); + assert.equal(f.setting.language, "multi"); + f.state.readFails = true; + await f.remount(); + await act(async () => { f.dictation.toggle(); }); + assert.match(f.dictation.error!, /Could not load/); + assert.equal(f.state.captures, 0); + assert.equal(Socket.all.length, 0); + f.state.readFails = false; + await act(async () => { f.dictation.toggle(); }); + assert.equal(Socket.all[0]!.language, "multi"); +}); + +test("a shortcut waits for a language save made by the separate settings control", async () => { + const f = await fixture(); + await f.mount(); + const write = deferred(); + f.state.nextWrite = write.promise; + let saved!: Promise; + await act(async () => { saved = f.setting.save("multi"); }); + await act(async () => { f.dictation.toggle(); }); + assert.equal(f.state.captures, 0); + f.state.record.dictation = { language: "multi" }; + await act(async () => { write.resolve(Response.json({ settings: f.state.record })); await saved; }); + assert.equal(Socket.all[0]!.language, "multi"); +}); + +test("a saved-language refresh gates capture even when an older choice is cached", async () => { + const f = await fixture(); + await f.mount(); + const read = deferred(); + f.state.nextRead = read.promise; + f.state.record.dictation = { language: "multi" }; + await act(async () => { notifySettingsSaved("dictation"); }); + await act(async () => { f.dictation.toggle(); }); + assert.equal(f.state.captures, 0); + assert.equal(Socket.all.length, 0); + await act(async () => { read.resolve(Response.json({ settings: f.state.record })); }); + assert.equal(Socket.all[0]!.language, "multi"); +}); + +test("a stale settings refresh cannot overwrite a newer confirmed choice", async () => { + const f = await fixture(); + await f.mount(); + const read = deferred(); + f.state.nextRead = read.promise; + await act(async () => { notifySettingsSaved("dictation"); }); + await act(async () => { await f.setting.save("multi"); }); + await act(async () => { read.resolve(Response.json({ settings: { version: 1, dictation: { language: "pl" } } })); }); + assert.equal(f.setting.language, "multi"); + await act(async () => { f.dictation.toggle(); }); + assert.equal(Socket.all[0]!.language, "multi"); +}); + +test("cancel while settings load does not open a microphone or socket later", async () => { + const read = deferred(); + const f = await fixture("pl", read.promise); + await f.mount(); + await act(async () => { f.dictation.toggle(); }); + await act(async () => { f.dictation.cancel(); }); + await act(async () => { read.resolve(Response.json({ settings: f.state.record })); }); + assert.equal(f.dictation.phase, "idle"); + assert.equal(f.state.captures, 0); + assert.equal(Socket.all.length, 0); +}); + +test("reopening settings during a save shares its busy state and rejects a competing selection", async () => { + const f = await fixture(); + await f.mount(); + const write = deferred(); + f.state.nextWrite = write.promise; + let first!: Promise; + await act(async () => { first = f.setting.save("multi"); }); + await f.remount(); + assert.equal(f.setting.saving, true); + assert.equal(await f.setting.save("en"), false, "a different selection cannot inherit another write's success"); + f.state.record.dictation = { language: "multi" }; + await act(async () => { write.resolve(Response.json({ settings: f.state.record })); await first; }); + assert.equal(f.setting.saving, false); + assert.equal(f.setting.language, "multi"); +}); + +test("a confirmed save applies to capture even when subsequent settings GET requests would fail", async () => { + const f = await fixture(); + await f.mount(); + f.state.readFails = true; + await act(async () => { assert.equal(await f.setting.save("multi"), true); }); + assert.equal(f.state.record.dictation?.language, "multi"); + await act(async () => { f.dictation.toggle(); }); + assert.equal(Socket.all[0]!.language, "multi"); +}); + +test("unmount during a pending language read prevents orphaned capture", async () => { + const read = deferred(); + const f = await fixture("pl", read.promise); + await f.mount(); + await act(async () => { f.dictation.toggle(); }); + await f.unmount(); + await act(async () => { read.resolve(Response.json({ settings: f.state.record })); }); + assert.equal(f.state.captures, 0); + assert.equal(Socket.all.length, 0); + assert.equal(isDictationActive(), false); +}); + +test("unmount during recording stops resources and discards the departing page's buffered text", async () => { + const f = await fixture(); + await f.mount(); + await act(async () => { f.dictation.toggle(); }); + const socket = Socket.all[0]!; + await act(async () => { socket.open(); socket.final("do not append into another page"); }); + await f.unmount(); + assert.equal(f.state.stops, 1); + assert.equal(socket.readyState, 3); + assert.equal(isDictationActive(), false); + assert.deepEqual(f.state.appends, []); + await act(async () => { socket.final("late event"); socket.end(1006); }); + assert.equal(Socket.all.length, 1); + assert.deepEqual(f.state.appends, []); +}); + +test("microphone permission resolving after unmount immediately stops the late stream", async () => { + const f = await fixture(); + await f.mount(); + const mic = deferred<{ getTracks: () => { stop: () => void }[] }>(); + f.state.nextMic = mic.promise; + await act(async () => { f.dictation.toggle(); }); + assert.equal(f.state.captures, 1); + await f.unmount(); + await act(async () => { mic.resolve({ getTracks: () => [{ stop() { f.state.stops++; } }] }); }); + assert.equal(f.state.stops, 1); + assert.equal(Socket.all.length, 0); + assert.equal(isDictationActive(), false); +}); diff --git a/tests/dictation-language.test.ts b/tests/dictation-language.test.ts new file mode 100644 index 0000000..2b75ad6 --- /dev/null +++ b/tests/dictation-language.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, describe, it } from "node:test"; +import { DICTATION_LANGUAGES, deepgramListenUrl, isDictationLanguage } from "../src/core/dictation.ts"; +import { sanitizeUserSettings } from "../src/core/settings.ts"; +import { resolveSettings } from "../src/core/settings-resolution.ts"; +import { parseDictationSettingsBody } from "../src/server/settings-body.ts"; + +describe("Nova-3 dictation language contract", () => { + it("offers monolingual languages/locales and a distinct multilingual mode", () => { + assert.equal(DICTATION_LANGUAGES[0]?.code, "multi"); + assert.equal(new Set(DICTATION_LANGUAGES.map((item) => item.code)).size, DICTATION_LANGUAGES.length); + for (const code of ["en", "pl", "en-GB", "pt-BR", "zh-Hant", "ar-EG", "multi"]) { + assert.ok(isDictationLanguage(code), code); + } + // Xhosa and Zulu are documented for multi, not monolingual Nova-3. + for (const code of ["xh", "zu", "en-CA", "auto", "xx", "pl&model=other", null, 3]) { + assert.equal(isDictationLanguage(code), false, String(code)); + } + const labels = DICTATION_LANGUAGES.slice(1).map((item) => item.label); + assert.deepEqual(labels, [...labels].sort((a, b) => a.localeCompare(b, "en"))); + }); + + it("only language varies in the streaming URL; all tuned parameters survive", () => { + for (const language of ["en", "pl", "multi", "en-GB"] as const) { + const url = new URL(deepgramListenUrl(language)); + assert.equal(url.origin + url.pathname, "wss://api.deepgram.com/v1/listen"); + assert.deepEqual(Object.fromEntries(url.searchParams), { + model: "nova-3", encoding: "linear16", sample_rate: "16000", channels: "1", + smart_format: "true", punctuate: "true", endpointing: "300", language, + }); + } + }); + + it("old records default to English and explicit preferences resolve without a migration", () => { + assert.equal(resolveSettings(null).dictation.language, "en"); + assert.equal(resolveSettings({ version: 1 }).dictation.language, "en"); + for (const language of ["pl", "multi", "en-GB"] as const) { + const record = sanitizeUserSettings({ version: 1, dictation: { language } }); + assert.equal(record?.version, 1); + assert.equal(resolveSettings(record).dictation.language, language); + } + }); + + it("rejects invalid API values and drops invalid stored preferences", () => { + assert.deepEqual(parseDictationSettingsBody({}), { ok: true, settings: {} }); + assert.deepEqual(parseDictationSettingsBody({ language: "multi" }), { ok: true, settings: { language: "multi" } }); + for (const value of [null, [], "pl", { language: "xx" }, { language: null }, { language: 3 }]) { + assert.equal(parseDictationSettingsBody(value).ok, false); + } + for (const dictation of [null, [], "pl", { language: "xx" }, { language: 3 }]) { + const record = sanitizeUserSettings({ version: 1, dictation, general: { autoUpdate: false } }); + assert.equal(resolveSettings(record).dictation.language, "en"); + assert.equal(record?.general?.autoUpdate, false); + } + }); +}); + +const testData = mkdtempSync(path.join(tmpdir(), "lh-dictation-settings-")); +process.env.LH_DATA_DIR = testData; +after(() => { rmSync(testData, { recursive: true, force: true }); }); +const { readSettings, writeSettings } = await import("../src/store/settings.ts"); + +it("the section route persists language independently and rejects unsupported values without writing", async () => { + const { PUT } = await import("../app/api/settings/[section]/route.ts"); + writeSettings({ version: 1, general: { autoUpdate: false } }); + const put = (language: string) => PUT(new Request("http://localhost/api/settings/dictation", { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ language }), + }), { params: Promise.resolve({ section: "dictation" }) }); + assert.equal((await put("pl")).status, 200); + const saved = { version: 1, dictation: { language: "pl" }, general: { autoUpdate: false } }; + assert.deepEqual(readSettings(), saved); + assert.equal((await put("unsupported")).status, 400); + assert.deepEqual(readSettings(), saved); +}); diff --git a/tests/dictation-picker.test.tsx b/tests/dictation-picker.test.tsx new file mode 100644 index 0000000..f33d350 --- /dev/null +++ b/tests/dictation-picker.test.tsx @@ -0,0 +1,197 @@ +import React, { act } from "react"; +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { parseHTML } from "linkedom"; +import { setDictationActive } from "../components/dictation-state.ts"; +import { DICTATION_LANGUAGES, type UserSettings } from "../src/core/index.ts"; + +let dispose: (() => Promise) | undefined; +let restore: (() => void) | undefined; +const byTest = (id: string) => document.querySelector(`[data-testid="${id}"]`)!; +const trigger = () => byTest("dictation-language"); +const input = () => document.querySelector('input[aria-label="Search dictation languages"]')!; +const menu = () => document.querySelector('[data-testid="dictation-language-menu"]'); +const click = async (element: HTMLElement) => { assert(element); await act(async () => { element.click(); }); }; +async function key(element: HTMLElement, value: string) { + await act(async () => { + const event = new window.Event("keydown", { bubbles: true, cancelable: true }); + Object.assign(event, { key: value }); + element.dispatchEvent(event); + }); +} +async function search(value: string) { + await act(async () => { + const element = input(); + Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call(element, value); + element.dispatchEvent(new window.Event("input", { bubbles: true })); + }); +} + +async function fixture(failRead = false) { + const { window: win, document: doc } = parseHTML("
"); + let active: Element = doc.body; + Object.defineProperty(doc, "activeElement", { get: () => active }); + Object.assign(doc, { oninput: null }); + Object.assign(win, { innerWidth: 1280, innerHeight: 900 }); + win.HTMLElement.prototype.focus = function () { active = this; }; + win.HTMLElement.prototype.scrollIntoView = () => {}; + win.HTMLElement.prototype.getBoundingClientRect = () => ({ top: 200, bottom: 230, left: 400, right: 500, width: 100, height: 30, x: 400, y: 200, toJSON() {} }); + const state = { + record: { version: 1, dictation: { language: "en" } } as UserSettings, + writes: [] as string[], failWrite: false, outerEscapes: 0, + failRead, + nextWrite: undefined as Promise | undefined, + }; + const previous = new Map(); + for (const [name, value] of Object.entries({ + React, window: win, document: doc, Element: win.Element, HTMLElement: win.HTMLElement, + HTMLInputElement: win.HTMLInputElement, HTMLTextAreaElement: win.HTMLTextAreaElement, + Event: win.Event, CustomEvent: win.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, + ResizeObserver: class { observe() {} unobserve() {} disconnect() {} }, + requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0), + cancelAnimationFrame: clearTimeout, + fetch: async (url: string, init?: RequestInit) => { + if (url === "/api/settings") return state.failRead ? Response.json({}, { status: 500 }) : Response.json({ settings: state.record }); + assert.equal(url, "/api/settings/dictation"); + const body = JSON.parse(init!.body as string); + state.writes.push(body.language); + if (state.nextWrite) { const next = state.nextWrite; state.nextWrite = undefined; return next; } + if (state.failWrite) return Response.json({}, { status: 500 }); + state.record = { ...state.record, dictation: body }; + return Response.json({ settings: state.record }); + }, + })) { + previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); + } + restore = () => { + for (const [name, descriptor] of previous) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else delete (globalThis as Record)[name]; + } + }; + // Register the outer dialog listener first: one Escape must close only + // the language menu, even when the settings dialog owns another listener. + doc.addEventListener("keydown", (event) => { if ((event as KeyboardEvent).key === "Escape") state.outerEscapes++; }); + const { createRoot } = await import("react-dom/client"); + const { DictationLanguageSetting } = await import("../components/dictation-language.tsx"); + let root = createRoot(doc.getElementById("root")!); + const mount = async () => { await act(async () => { root.render(); }); }; + dispose = async () => { await act(async () => { setDictationActive(false); root.unmount(); }); }; + await mount(); + return { state, remount: async () => { await dispose!(); root = createRoot(doc.getElementById("root")!); await mount(); } }; +} + +afterEach(async () => { await dispose?.(); restore?.(); dispose = undefined; restore = undefined; }); + +test("an initial load failure is visible and opening the menu again can retry", async () => { + const f = await fixture(true); + await click(trigger()); + assert.match(menu()!.querySelector('[role="alert"]')!.textContent!, /Could not load/); + f.state.failRead = false; + await click(trigger()); + await click(trigger()); + assert.equal(menu()!.querySelector('[role="alert"]'), null); + await search("Polish"); + await key(input(), "Enter"); + assert.match(trigger().textContent!, /Polish/); +}); + +test("the real picker searches by language, chooses with Arrow/Enter, and restores focus", async () => { + const f = await fixture(); + assert.match(trigger().textContent!, /English/); + assert.equal(trigger().getAttribute("data-variant"), "outline"); + await click(trigger()); + assert.equal(input().getAttribute("data-slot"), "input"); + assert.equal(input().getAttribute("role"), "combobox"); + assert.equal(document.activeElement, input()); + assert.equal(document.querySelector("[cmdk-item]")!.textContent, "Multilingual"); + await search("Polish"); + assert.equal(document.querySelectorAll("[cmdk-item]").length, 1); + await key(input(), "ArrowDown"); + await key(input(), "Enter"); + assert.deepEqual(f.state.writes, ["pl"]); + assert.match(trigger().textContent!, /Polish/); + assert.equal(menu(), null); + assert.equal(document.activeElement, trigger()); + await f.remount(); + assert.match(trigger().textContent!, /Polish/); +}); + +test("empty results cannot save; Escape and outside click close the menu without closing settings", async () => { + const f = await fixture(); + await click(trigger()); + await search("zzzz-no-language"); + assert.match(document.querySelector("[cmdk-empty]")!.textContent!, /No languages match/); + await key(input(), "Enter"); + assert.deepEqual(f.state.writes, []); + await key(input(), "Escape"); + assert.equal(menu(), null); + assert.equal(f.state.outerEscapes, 0); + await click(trigger()); + await key(trigger(), "Escape"); + assert.equal(menu(), null); + assert.equal(f.state.outerEscapes, 0); + await click(trigger()); + await act(async () => { document.body.dispatchEvent(new window.Event("pointerdown", { bubbles: true })); }); + assert.equal(menu(), null); +}); + +test("failed saves retain the confirmed choice and the same menu can retry", async () => { + const f = await fixture(); + f.state.failWrite = true; + await click(trigger()); + await search("Polish"); + await key(input(), "Enter"); + assert.match(trigger().textContent!, /English/); + assert.match(menu()!.querySelector('[role="alert"]')!.textContent!, /Could not save/); + f.state.failWrite = false; + await click(byTest("dictation-language-pl")); + assert.equal(menu(), null); + assert.match(trigger().textContent!, /Polish/); + assert.deepEqual(f.state.writes, ["pl", "pl"]); +}); + +test("a remounted picker stays disabled throughout an outstanding save", async () => { + const f = await fixture(); + let complete!: (response: Response) => void; + f.state.nextWrite = new Promise((resolve) => { complete = resolve; }); + await click(trigger()); + await click(byTest("dictation-language-multi")); + assert.equal(trigger().disabled, true); + await f.remount(); + assert.equal(trigger().disabled, true); + await click(trigger()); + assert.equal(menu(), null); + f.state.record.dictation = { language: "multi" }; + await act(async () => { complete(Response.json({ settings: f.state.record })); }); + assert.equal(trigger().disabled, false); + assert.match(trigger().textContent!, /Multilingual/); + assert.deepEqual(f.state.writes, ["multi"]); +}); + +test("capture locks the row and dismisses an open language menu until capture ends", async () => { + await fixture(); + await click(trigger()); + await act(async () => { setDictationActive(true); }); + assert.equal(trigger().disabled, true); + assert.match(byTest("row-dictation-language").className, /opacity-40/); + assert.equal(menu(), null); + await act(async () => { setDictationActive(false); }); + assert.equal(trigger().disabled, false); + await click(trigger()); + assert(menu()); +}); + +test("clearing each search restores the complete catalog order with Multilingual first", async () => { + await fixture(); + await click(trigger()); + const order = () => [...document.querySelectorAll("[cmdk-item]")].map((item) => item.getAttribute("data-value")); + const expected = DICTATION_LANGUAGES.map((item) => item.code); + assert.deepEqual(order(), expected); + for (const query of ["en", "multi", "Polish", "zzzzz"]) { + await search(query); + await search(""); + assert.deepEqual(order(), expected, `catalog order after clearing ${query}`); + } +}); diff --git a/tests/dictation-preference.test.ts b/tests/dictation-preference.test.ts new file mode 100644 index 0000000..4b9b7d4 --- /dev/null +++ b/tests/dictation-preference.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createDictationLanguageStore } from "../components/dictation-preference.ts"; + +test("all readers immediately receive the confirmed write without another GET", async () => { + let reads = 0; + let notifications = 0; + let failReads = false; + const store = createDictationLanguageStore(async (_url, init) => { + if (init?.method === "PUT") return Response.json({ settings: { version: 1, dictation: JSON.parse(init.body as string) } }); + reads++; + return failReads ? Response.json({}, { status: 500 }) : Response.json({ settings: null }); + }, () => { notifications++; }); + assert.equal(await store.load(), "en"); + const observed: string[] = []; + const off = store.subscribe(() => { observed.push(`${store.getSnapshot().language}:${store.getSnapshot().saving}`); }); + failReads = true; + assert.equal(await store.save("multi"), true); + assert.equal(await store.getLanguage(), "multi"); + assert.equal(reads, 1); + assert.equal(notifications, 1); + assert(observed.includes("multi:true")); + assert.equal(observed.at(-1), "multi:false"); + off(); +}); + +test("a failed external refresh invalidates the cache until a successful retry", async () => { + let fail = false; + const store = createDictationLanguageStore(async () => fail + ? Response.json({}, { status: 500 }) + : Response.json({ settings: { version: 1, dictation: { language: "pl" } } })); + await store.load(); + fail = true; + await assert.rejects(store.load(true), /Could not load/); + await assert.rejects(store.getLanguage(), /Could not load/); + fail = false; + assert.equal(await store.getLanguage(), "pl"); + assert.equal(store.getSnapshot().error, null); +}); + +test("stale reads cannot overwrite a newer write and subscribers see busy before transport runs", async () => { + let stale!: (response: Response) => void; + let nextRead: Promise | undefined; + const store = createDictationLanguageStore(async (_url, init) => { + if (init?.method === "PUT") return Response.json({ settings: { version: 1, dictation: { language: "multi" } } }); + if (nextRead) { const response = nextRead; nextRead = undefined; return response; } + return Response.json({ settings: { version: 1, dictation: { language: "pl" } } }); + }); + await store.load(); + nextRead = new Promise((resolve) => { stale = resolve; }); + const oldRead = store.load(true); + await Promise.resolve(); + let startedDuringPublish: Promise | undefined; + const off = store.subscribe(() => { + if (store.getSnapshot().saving && !startedDuringPublish) startedDuringPublish = store.getLanguage(); + }); + assert.equal(await store.save("multi"), true); + stale(Response.json({ settings: { version: 1, dictation: { language: "pl" } } })); + assert.equal(await oldRead, "multi"); + assert.equal(await startedDuringPublish, "multi"); + assert.equal(store.getSnapshot().language, "multi"); + off(); +}); + +for (const newestFirst of [false, true]) test(`overlapping reads keep the newest value, newest resolves first: ${newestFirst}`, async () => { + const responses: ((response: Response) => void)[] = []; + const store = createDictationLanguageStore(() => new Promise((resolve) => { responses.push(resolve); })); + const first = store.load(); + await Promise.resolve(); + const second = store.load(true); + await Promise.resolve(); + const old = Response.json({ settings: { version: 1, dictation: { language: "pl" } } }); + const fresh = Response.json({ settings: { version: 1, dictation: { language: "multi" } } }); + if (newestFirst) { responses[1]!(fresh); responses[0]!(old); } + else { responses[0]!(old); responses[1]!(fresh); } + assert.deepEqual(await Promise.all([first, second]), ["multi", "multi"]); + assert.equal(await store.getLanguage(), "multi"); +}); + +test("capture also waits for a write initiated as a pending read publishes its result", async () => { + const store = createDictationLanguageStore(async (_url, init) => Response.json({ settings: { + version: 1, dictation: { language: init?.method === "PUT" ? "multi" : "en" }, + } })); + let started = false; + const off = store.subscribe(() => { + if (!started && store.getSnapshot().language === "en") { + started = true; + void store.save("multi"); + } + }); + assert.equal(await store.getLanguage(), "multi"); + off(); +}); diff --git a/tests/settings-body.test.ts b/tests/settings-body.test.ts index e543d45..16c0ac9 100644 --- a/tests/settings-body.test.ts +++ b/tests/settings-body.test.ts @@ -234,12 +234,12 @@ describe("parseCompactionSettingsBody (owner round 14)", () => { }); describe("SETTINGS_SECTIONS (the route's section map)", () => { - it("maps exactly the seven record sections", () => { + it("maps exactly the eight record sections", () => { // compaction joined (owner round 14): its own section PUT — the // threshold is engine behavior, not an agent-section key. assert.deepEqual( Object.keys(SETTINGS_SECTIONS), - ["general", "chat", "agent", "appearance", "graph", "compaction", "models"], + ["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation"], ); }); }); From 51c3ea0fdecc65d58b5c785cc13f456cdd750876 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sun, 25 Oct 2026 18:23:23 +0200 Subject: [PATCH 14/87] chore: add issue forms and reporting guidance --- .github/ISSUE_TEMPLATE/bug_report.yml | 67 ++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.yml | 17 ++++++ .github/ISSUE_TEMPLATE/question.yml | 15 +++++ 4 files changed, 100 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/question.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..ae5a75d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,67 @@ +name: Bug report +description: Report something that is not working as expected. +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for helping improve LearnHarness. Describe what you observed; you do not need to know the cause. + + **This report is public.** Remove API keys, tokens, passwords and private information before posting. Never attach credential files or your whole data folder. Share only the learning content you are comfortable making public. + - type: textarea + id: description + attributes: + label: What happened? + description: Describe the problem and what you expected to happen instead. + validations: + required: true + - type: input + id: version + attributes: + label: LearnHarness version or build + description: Include the app version, installer filename or source commit. If you cannot find it, write "unknown". + validations: + required: true + - type: input + id: operating_system + attributes: + label: Operating system + description: For example, Windows, macOS or your Linux distribution. Include the OS version if known. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Steps or context (optional) + description: How can we reproduce it? If it is intermittent or you are unsure, describe what you were doing when it happened. + validations: + required: false + - type: input + id: installation + attributes: + label: Installation method (optional) + description: For example, desktop installer, AppImage, system package, self-hosted web or source build. + validations: + required: false + - type: input + id: provider_model + attributes: + label: Provider and model (if relevant) + description: Include the provider, model and API-key or subscription connection type if relevant. Do not include any keys or tokens. + validations: + required: false + - type: textarea + id: transcript_excerpt + attributes: + label: Sanitized raw JSON or chat-log excerpt (optional) + description: A few relevant entries around the affected turn or tool call are especially helpful. Paste the excerpt directly, preserving JSON escaping where possible. Remove credentials, personal information and private learning content first. Leave this blank if the records are difficult to find. + render: json + validations: + required: false + - type: textarea + id: screenshots + attributes: + label: Screenshots or other context (optional) + description: Attach screenshots or add other relevant details. Check for private information before posting. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0086358 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..456ac46 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,17 @@ +name: Feature request +description: Suggest a new feature or an improvement. +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + Ideas are welcome; you do not need a finished design or implementation plan. A request opens a discussion, not a commitment to build it. + + **This report is public.** Do not include credentials or private information. + - type: textarea + id: idea + attributes: + label: What would you like to see? + description: Describe your idea and the problem it would help you solve. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..29d8d91 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,15 @@ +name: Question +description: Ask about using LearnHarness. +labels: [question] +body: + - type: markdown + attributes: + value: | + **This report is public.** Do not include credentials or private information. + - type: textarea + id: question + attributes: + label: What is your question? + description: Include any relevant context. Version, OS, provider or model details can help when they relate to your question. + validations: + required: true From 0795ef197cca04f6a8e7d50d10ed4dc71bd1a0cc Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sun, 25 Oct 2026 18:59:48 +0200 Subject: [PATCH 15/87] test(chatgpt): await repeated sign-in completion --- tests/chatgpt-auth.test.ts | 42 ++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/tests/chatgpt-auth.test.ts b/tests/chatgpt-auth.test.ts index fb09716..14c3baa 100644 --- a/tests/chatgpt-auth.test.ts +++ b/tests/chatgpt-auth.test.ts @@ -445,20 +445,50 @@ test("scope-disabled credentials remain revocable and unconfirmed revoke never c } finally { f.service.close(); } }); -test("repeated real verified sign-in creates one visible account while preserving separately bound registrations until explicit forgetting", async () => { - const f = await loggedIn(); +for (const holdVerification of [false, true]) test(`repeated real verified sign-in creates one visible account while preserving separately bound registrations until explicit forgetting${holdVerification ? " (held JWKS regression)" : ""}`, { timeout: 10000 }, async () => { + const repeatComplete = deferred(); + const verificationEntered = deferred(), verificationRelease = deferred(); + let repeatArmed = false, sawPending = false, completed = false; + const f = await loggedIn({ + change: (push) => { + if (!repeatArmed) return; + if (push.status.pending) sawPending = true; + else if (sawPending) { completed = true; repeatComplete.resolve(push.status); } + }, + provider: async (url) => { + if (holdVerification && repeatArmed && url.endsWith("jwks.json")) { + verificationEntered.resolve(); await verificationRelease.promise; + } + return undefined; + }, + }); try { const original = f.saved.registrations[0]; - await f.service.login(); await f.callback({ client_id: "oaiapp_repeat" }); - for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((r) => setImmediate(r)); - const status = await f.service.status(); + const writesBeforeRepeat = f.writeCount; + // Initial login already settled fixture.changed; arm for this attempt's terminal notification. + repeatArmed = true; + await f.service.login(); assert.equal(sawPending, true); + assert.equal((await f.callback({ client_id: "oaiapp_repeat" })).status, 200); + if (holdVerification) { + await verificationEntered.promise; + assert.equal(completed, false, "HTTP acknowledgement cannot complete pending identity verification"); + const held = await f.service.status(); + assert.equal(held.pending, true); assert.equal(held.error, null); + assert.equal(held.activeId, original.id); assert.equal(held.accounts.length, 1); + assert.deepEqual(f.saved.registrations, [original]); + assert.equal(f.writeCount, writesBeforeRepeat, "unverified repeat sign-in cannot persist credentials"); + } + verificationRelease.resolve(); + const status = await repeatComplete.promise; + assert.equal(status.pending, false); assert.equal(status.error, null); + assert.equal(f.writeCount, writesBeforeRepeat + 1, "repeat sign-in completes after durable credential persistence"); assert.equal(f.saved.registrations.length, 2); assert.equal(status.accounts.length, 1); assert.equal(status.activeId, status.accounts[0].id); assert.notEqual(status.activeId, original.id); assert.equal(f.saved.registrations[0].clientId, original.clientId); assert.equal(f.saved.registrations[1].clientId, "oaiapp_repeat"); await f.service.disconnect(status.activeId); assert.deepEqual(f.saved.registrations, []); assert.equal((await f.service.status()).accounts.length, 0); assert.equal(f.requests.filter((r) => r.form?.has("token_type_hint")).length, 2); - } finally { f.service.close(); } + } finally { verificationRelease.resolve(); f.service.close(); } }); test("verified identity grouping hides legacy duplicate registrations without merging client credentials or matching emails", async () => { From 769fc85e17d531a80f33ac8f2bf2ef2f5b57c701 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sun, 25 Oct 2026 19:19:38 +0200 Subject: [PATCH 16/87] fix: honor automatic updates preference and add Settings controls --- components/settings/controls.tsx | 2 +- components/settings/settings-dialog.tsx | 27 +-- components/settings/update-settings.tsx | 113 +++++++++ components/sidebar.tsx | 9 +- components/update-banner.tsx | 23 +- electron/main.cjs | 105 +++++++-- electron/preload.cjs | 30 ++- next.config.ts | 5 + src/core/settings.ts | 2 +- tests/automatic-updates.test.ts | 299 ++++++++++++++++++++++++ tests/desktop-platform.test.ts | 38 ++- tests/render-update-banner.test.tsx | 15 +- tests/render-update-settings.test.tsx | 223 ++++++++++++++++++ tests/tooltip-mechanism.test.tsx | 4 +- 14 files changed, 820 insertions(+), 75 deletions(-) create mode 100644 components/settings/update-settings.tsx create mode 100644 tests/automatic-updates.test.ts create mode 100644 tests/render-update-settings.test.tsx diff --git a/components/settings/controls.tsx b/components/settings/controls.tsx index c7ee2f6..4aaa385 100644 --- a/components/settings/controls.tsx +++ b/components/settings/controls.tsx @@ -63,7 +63,7 @@ export function SettingsRow({ dimmed, }: { label: string; - hint?: string; + hint?: ReactNode; children: React.ReactNode; testid?: string; /** Dependency graying, graph-view style: the whole row dims when the diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx index 1142bdf..9145e0c 100644 --- a/components/settings/settings-dialog.tsx +++ b/components/settings/settings-dialog.tsx @@ -40,7 +40,8 @@ import { type UserSettings, } from "@/src/core"; import { useAppearance } from "./appearance.tsx"; -import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx"; +import { SettingsSection } from "./controls.tsx"; +import { UpdateSettings } from "./update-settings.tsx"; import { AgentSection } from "./agent-section.tsx"; import type { ModelsSettings } from "@/src/core"; import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "@/components/menu-shell"; @@ -75,10 +76,6 @@ const DialogContext = createContext<{ open: (section?: string, row?: string) => /** AppShell-level mount: the sidebar row and the mod+, chord call this. * `open(section)` lands the dialog on a specific section (the first-run * setup flow deep-links to Keys when no provider key exists yet). */ -/** The auto-update row's hint (tooltip wave): honest user language — no - * stream vocabulary. Exported for the copy pin (tooltip-mechanism test). */ -export const AUTO_UPDATE_HINT = "Automatic updates arrive in an upcoming release"; - export function SettingsDialogProvider({ children, }: { @@ -92,6 +89,7 @@ export function SettingsDialogProvider({ setLandOnRow(row ?? null); setShow(true); }, []); + useEffect(() => window.lh?.onOpenSettings?.((section) => open(section)), [open]); return ( {children} @@ -362,19 +360,12 @@ export function SettingsDialogBody({ title="General" testid="section-general" > - - - void write("general", { autoUpdate: on }) - } - /> - + + void write("general", { ...record.general, autoUpdate: on }) + } + /> )} {active === "keys" && ( diff --git a/components/settings/update-settings.tsx b/components/settings/update-settings.tsx new file mode 100644 index 0000000..71b948d --- /dev/null +++ b/components/settings/update-settings.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { BrailleSpinner } from "@/components/braille-spinner"; +import { SettingsRow, Toggle } from "./controls.tsx"; + +/** Observational snapshot from electron/main.cjs; never updater permission. */ +export type UpdateState = { + status: "idle" | "checking" | "downloading" | "up-to-date" | "downloaded" | "error" | "disabled"; + version: string | null; + revision: number; +}; + +export const APP_VERSION = process.env.LH_VERSION ?? ""; +const HOMEPAGE = process.env.LH_HOMEPAGE ?? ""; +export const MANUAL_UPDATE_HINT = "Checking downloads available updates. Downloaded updates install when you quit the app."; +export const AUTO_UPDATE_HINT = "Check at startup and every 24 hours. Turning this off does not stop updates already in progress or ready to install."; +const UPDATE_ERROR = "Couldn’t check for updates. Try again later."; + +/** Subscribe before requesting the snapshot. IPC replies and pushes can + * cross; a revision prevents an older reply from hiding a newer event. + * The banner shares this read-only observer so late loads recover too. */ +export function useUpdateState() { + const [state, setState] = useState(null); + const [failed, setFailed] = useState(false); + useEffect(() => { + const lh = window.lh; + if (!lh) return; + let live = true; + let revision = -1; + const accept = (next: UpdateState) => { + if (!live || next.revision < revision) return; + revision = next.revision; + setState(next); + setFailed(false); + }; + const off = lh.onUpdateStatus?.(accept); + void lh.getUpdateState?.().then(accept).catch(() => { + if (live && revision === -1) setFailed(true); + }); + return () => { live = false; off?.(); }; + }, []); + return { state, failed }; +} + +export function updateStatusText(state: UpdateState | null): string | null { + switch (state?.status) { + case "checking": return "Checking for updates…"; + case "downloading": return state.version ? `Downloading version ${state.version}…` : "Downloading an update…"; + case "up-to-date": return "No newer version was found at the last check."; + case "downloaded": return `${state.version ? `Version ${state.version}` : "An update"} is ready. It will install when you quit the app.`; + case "error": return UPDATE_ERROR; + default: return null; + } +} + +/** Version information is useful everywhere; update controls only belong + * to a shell with an available updater. Opening this row never checks. */ +export function UpdateSettings({ + autoUpdate, + onAutoUpdateChange, + version = APP_VERSION, + homepage = HOMEPAGE, +}: { + autoUpdate: boolean; + onAutoUpdateChange: (on: boolean) => void; + version?: string; + homepage?: string; +}) { + const { state, failed } = useUpdateState(); + const [checkFailed, setCheckFailed] = useState(false); + useEffect(() => setCheckFailed(false), [state]); + const active = state !== null && state.status !== "disabled"; + const busy = state?.status === "checking" || state?.status === "downloading"; + const status = state?.status === "downloaded" ? updateStatusText(state) + : failed || checkFailed ? UPDATE_ERROR : updateStatusText(state); + const releaseNotes = version && homepage ? `${homepage}/releases/tag/v${version}` : null; + const check = () => { + setCheckFailed(false); + void window.lh?.checkForUpdates?.().catch(() => setCheckFailed(true)); + }; + + return ( + <> + + {status && {status}} + {active && {MANUAL_UPDATE_HINT}} + {releaseNotes && Release notes} + + } + > + {active && (state.status === "downloaded" ? ( + + ) : ( + + ))} + + {active && ( + + + + )} + + ); +} diff --git a/components/sidebar.tsx b/components/sidebar.tsx index d853f5c..6ca9c21 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -49,6 +49,7 @@ const STORAGE_KEY = "lh:sidebar-open"; * Mirrors SIDEBAR_AUTOCLOSE in electron/main.cjs — keep in sync. */ import type { ChatGPTControlBridge, NativeSubscriptionControlBridge } from "@/src/core"; +import type { UpdateState } from "@/components/settings/update-settings.tsx"; const SIDEBAR_AUTOCLOSE = 928; // Module scope, guarded for SSR (the server has no navigator). @@ -66,9 +67,13 @@ declare global { /** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */ onZoom?: (cb: (factor: number) => void) => () => void; getZoom?: () => Promise; - /** Auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes once a staged update exists. */ - onUpdateStatus?: (cb: (status: { status: string; version: string | null }) => void) => () => void; + /** Observational update events and a snapshot for late subscribers. */ + onUpdateStatus?: (cb: (status: UpdateState) => void) => () => void; + getUpdateState?: () => Promise; + /** Explicit manual check also downloads and stages available updates. */ + checkForUpdates?: () => Promise; installUpdate?: () => void; + onOpenSettings?: (cb: (section: string) => void) => () => void; }; } } diff --git a/components/update-banner.tsx b/components/update-banner.tsx index cd63030..a769ec2 100644 --- a/components/update-banner.tsx +++ b/components/update-banner.tsx @@ -3,32 +3,19 @@ /** * The auto-update banner (Wave C, owner 2026-10-10: silent download + * restart-banner UX). The shell pushes "lh:update-status" once a staged - * update exists (electron/main.cjs update-downloaded); this is the whole - * renderer surface — a small fixed card, bottom-right, offering Restart. + * update exists (electron/main.cjs update-downloaded). Snapshot retrieval + * also covers a late load. The card offers Restart alongside Settings. * Browser builds have no window.lh and never see a push: renders null. * * No dismiss: a downloaded update installs on quit anyway * (autoInstallOnAppQuit); the banner is a courtesy, not a gate. */ -import { useEffect, useState } from "react"; - -// The window.lh bridge type lives in components/sidebar.tsx (the incumbent -// declare-global) — onUpdateStatus/installUpdate are declared there too. - -type UpdateStatus = { status: string; version: string | null }; +import { useUpdateState } from "@/components/settings/update-settings.tsx"; export default function UpdateBanner() { - const [ready, setReady] = useState(null); - - useEffect(() => { - // Absent bridge (browser / dev) → stays hidden forever. - return window.lh?.onUpdateStatus?.((s) => { - if (s?.status === "downloaded") setReady(s); - }); - }, []); - - if (!ready) return null; + const { state: ready } = useUpdateState(); + if (ready?.status !== "downloaded") return null; return (
{ - // The download itself is silent (autoDownload); the banner fires on - // update-downloaded. Manual checks additionally answer directly. - if (!manual) return; - const version = result?.updateInfo?.version; - if (!version) dialog.showMessageBox({ type: "info", message: "You're up to date.", detail: `LearnHarness ${app.getVersion()} is the latest version.` }); + // A declined check (e.g. an unsupported package) establishes no + // latest-version claim. Real results arrive through updater events. + if (!result) reportUpdateStatus("idle"); }) .catch((err) => { log(`update check failed: ${err?.message ?? err}`); - if (manual) dialog.showMessageBox({ type: "warning", message: "Couldn't check for updates.", detail: String(err?.message ?? err) }); + reportUpdateStatus("error"); }); } @@ -477,12 +504,14 @@ function installUpdateNow() { function setupAutoUpdater() { if (!app.isPackaged) { log("auto-update: dev build — updater disabled"); + reportUpdateStatus("disabled"); return; } try { autoUpdater = require("electron-updater").autoUpdater; } catch (err) { log(`auto-update: electron-updater unavailable (${err?.message ?? err}) — updates disabled`); + reportUpdateStatus("disabled"); return; } const feed = process.env.LH_UPDATE_FEED; @@ -500,11 +529,37 @@ function setupAutoUpdater() { debug: (m) => {}, // too chatty for the persistent log error: (m) => log(`updater error: ${m}`), }; + autoUpdater.on("checking-for-update", () => reportUpdateStatus("checking")); + autoUpdater.on("update-available", (info) => reportUpdateStatus("downloading", info?.version)); + autoUpdater.on("update-not-available", () => reportUpdateStatus("up-to-date")); autoUpdater.on("update-downloaded", (info) => { log(`update downloaded: ${info?.version} — banner shown`); - broadcastUpdateStatus({ status: "downloaded", version: info?.version ?? null }); + reportUpdateStatus("downloaded", info?.version); }); - autoUpdater.on("error", (err) => log(`updater error: ${err?.message ?? err}`)); + autoUpdater.on("error", (err) => { + log(`updater error: ${err?.message ?? err}`); + reportUpdateStatus("error"); + }); +} + +/** One result surface for manual checks. Preload buffers the navigation + * request until React subscribes, including when all macOS windows closed. */ +function checkForUpdatesFromMenu() { + try { + const win = BrowserWindow.getAllWindows()[0] ?? reopenWindow?.(); + if (win) { + if (win.isMinimized()) win.restore(); + win.focus(); + const openSettings = () => { + try { win.webContents.send("lh:open-settings", "general"); } catch { /* window closed */ } + }; + if (win.webContents.isLoadingMainFrame()) win.webContents.once("did-finish-load", openSettings); + else openSettings(); + } + } catch { + /* Navigation failure must not prevent the manual check. */ + } + checkForUpdates({ manual: true }); } function zoomStep(win, dir) { @@ -530,9 +585,8 @@ function installMenu() { label: app.name, submenu: [ { role: "about" }, - // Manual check (Wave C): answers via native dialog — no renderer - // round-trip. The 24h automatic checks stay silent. - { label: "Check for Updates…", click: () => checkForUpdates({ manual: true }) }, + // Manual checks report their result in Settings → General. + { label: "Check for Updates…", click: () => checkForUpdatesFromMenu() }, { type: "separator" }, { role: "hide" }, { role: "hideOthers" }, @@ -654,6 +708,14 @@ async function main() { // are log lines — this can never block boot. setupAutoUpdater(); ipcMain.on("lh:update-install", () => installUpdateNow()); + ipcMain.handle("lh:update:get", (event) => { + if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted desktop request."); + return updateState; + }); + ipcMain.handle("lh:update:check", (event) => { + if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted desktop request."); + checkForUpdates({ manual: true }); + }); // Sidebar-at-narrow-window (owner, 2026-09-17): the renderer auto-closes // the sidebar below SIDEBAR_AUTOCLOSE (928px); opening it again at that @@ -670,13 +732,14 @@ async function main() { const target = SIDEBAR_AUTOCLOSE; if (width < target) win.setBounds({ x, y, width: target, height }); }); + reopenWindow = () => { chatgpt?.resume(); subscriptions?.resume(); return createWindow(url); }; app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { chatgpt?.resume(); subscriptions?.resume(); createWindow(url); } + if (BrowserWindow.getAllWindows().length === 0) reopenWindow(); }); createWindow(url); log(`window open: ${url}`); - // First update check AFTER the window exists (the downloaded-banner - // push needs a listener); dev builds no-op (no autoUpdater). + // Startup + 24h checks stay main-owned and read the current preference. + // No renderer subscription is required; late loads retrieve the snapshot. if (autoUpdater) { checkForUpdates(); setInterval(() => checkForUpdates(), UPDATE_CHECK_INTERVAL_MS); diff --git a/electron/preload.cjs b/electron/preload.cjs index 4912005..9294291 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -19,14 +19,25 @@ * * window.lh.onUpdateStatus(cb) / window.lh.installUpdate() — the * auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes - * {status:"downloaded", version} once an update is staged; installUpdate - * asks main to kill the server and quit-and-install. Browser builds: - * both absent, the banner never renders. + * {status, version, revision} on updater events; installUpdate asks main + * to kill the server and quit-and-install. getUpdateState retrieves the + * snapshot for a late subscriber; checkForUpdates requests a manual + * check (including download/install-on-quit, even with automatic checks OFF). + * + * window.lh.onOpenSettings(cb) — menu navigation, buffered until React + * subscribes after a window is reopened. Browser builds have no bridge. * * Channel names are mirrored in electron/main.cjs — keep them in sync. */ const { contextBridge, ipcRenderer } = require("electron"); +let openSettingsListener = null; +let pendingOpenSettings = null; +ipcRenderer.on("lh:open-settings", (_event, section) => { + if (openSettingsListener) openSettingsListener(section); + else pendingOpenSettings = section; +}); + contextBridge.exposeInMainWorld("lh", { platform: process.platform, chatgpt: { @@ -71,4 +82,17 @@ contextBridge.exposeInMainWorld("lh", { return () => ipcRenderer.removeListener("lh:update-status", listener); }, installUpdate: () => ipcRenderer.send("lh:update-install"), + getUpdateState: () => ipcRenderer.invoke("lh:update:get"), + checkForUpdates: () => ipcRenderer.invoke("lh:update:check"), + onOpenSettings: (cb) => { + openSettingsListener = cb; + if (pendingOpenSettings !== null) { + const section = pendingOpenSettings; + pendingOpenSettings = null; + cb(section); + } + return () => { + if (openSettingsListener === cb) openSettingsListener = null; + }; + }, }); diff --git a/next.config.ts b/next.config.ts index db9352e..94193d8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,11 @@ +import { readFileSync } from "node:fs"; import type { NextConfig } from "next"; +const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")) as { version: string; homepage: string }; + const nextConfig: NextConfig = { + // Public build metadata only; no new runtime Electron version API. + env: { LH_VERSION: pkg.version, LH_HOMEPAGE: pkg.homepage }, // Desktop target (Electron wrapper, 2026-09-17): standalone output = a // self-contained .next/standalone/server.js the Electron shell spawns // on 127.0.0.1. Harmless for plain web deploys (extra build output). diff --git a/src/core/settings.ts b/src/core/settings.ts index 4daece8..ce80c65 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -231,7 +231,7 @@ export const ROLE_SLOT: Readonly> = { /** General settings — app-level behavior. Version DISPLAY and plans login * live outside the record (package.json / auth.json) by owner decision. */ export interface GeneralSettings { - /** Auto-update the app (default ON). Mechanism ships in a later wave. */ + /** Automatic startup/24h update checks (default ON). Manual checks bypass this. */ autoUpdate?: boolean; } diff --git a/tests/automatic-updates.test.ts b/tests/automatic-updates.test.ts new file mode 100644 index 0000000..5875a7e --- /dev/null +++ b/tests/automatic-updates.test.ts @@ -0,0 +1,299 @@ +/** Actual shell functions with fake Electron/updater/filesystem. No app, + * profile, network, timer or updater download/install operation is run. */ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createContext, runInContext } from "node:vm"; +import { describe, it } from "node:test"; + +const mainFile = fileURLToPath(new URL("../electron/main.cjs", import.meta.url)); +const mainSource = readFileSync(mainFile, "utf8"); +const mainRequire = createRequire(mainFile); + +function fixture({ packaged = true, updaterMissing = false } = {}) { + let settings: string | Error = JSON.stringify({ version: 1, general: { autoUpdate: true } }); + const settingsReads: string[] = []; + const order: string[] = []; + const intervals: { callback: () => void; delay: number }[] = []; + const handles = new Map unknown>(); + const windows: FakeWindow[] = []; + let enumerationFails = false; + let userData = "/fixture/custom-user-data"; + let menu: { submenu?: { label?: string; click?: () => void }[] }[] = []; + class FakeUpdater extends EventEmitter { + checks = 0; + autoDownload = false; + autoInstallOnAppQuit = false; + result: object | null = { updateInfo: { version: "0.3.7" } }; + failure: Error | null = null; + checkForUpdates() { + this.checks++; + this.emit("checking-for-update"); + if (this.failure) return Promise.reject(this.failure); + if (this.result) this.emit("update-not-available", { version: "0.3.7" }); + return Promise.resolve(this.result); + } + quitAndInstall() { order.push("install"); } + } + const updater = new FakeUpdater(); + class FakeWebContents extends EventEmitter { + mainFrame = { url: "http://127.0.0.1:9000/" }; + messages: { channel: string; value: unknown }[] = []; + loading = false; + fails = false; + send(channel: string, value: unknown) { + if (this.fails) throw new Error("window destroyed"); + this.messages.push({ channel, value: JSON.parse(JSON.stringify(value)) }); + } + isLoadingMainFrame() { return this.loading; } + setWindowOpenHandler() {} + getZoomFactor() { return 1; } + } + class FakeWindow extends EventEmitter { + webContents = new FakeWebContents(); + minimized = false; + focused = false; + constructor() { super(); windows.push(this); } + loadURL() { this.webContents.loading = true; } + isMinimized() { return this.minimized; } + restore() { this.minimized = false; } + focus() { this.focused = true; } + show() {} + static getAllWindows() { + if (enumerationFails) throw new Error("window enumeration failed"); + return windows; + } + static fromWebContents(contents: FakeWebContents) { return windows.find(win => win.webContents === contents); } + } + const app = Object.assign(new EventEmitter(), { + isPackaged: packaged, + name: "LearnHarness", + setName() {}, + getPath: () => userData, + setPath: (_: string, value: string) => { userData = value; }, + requestSingleInstanceLock: () => false, + quit() {}, + whenReady: async () => {}, + }); + const electron = { + app, BrowserWindow: FakeWindow, + Menu: { buildFromTemplate: (value: typeof menu) => value, setApplicationMenu: (value: typeof menu) => { menu = value; } }, + ipcMain: { on() {}, handle: (channel: string, fn: (event: unknown) => unknown) => handles.set(channel, fn) }, + session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler() {} } }, + }; + const context = createContext({ + require: (id: string) => { + if (id === "electron") return electron; + if (id === "electron-updater") { + if (updaterMissing) throw new Error("missing updater"); + return { autoUpdater: updater }; + } + if (id === "node:fs") return { + readFileSync: (file: string) => { + settingsReads.push(file); + if (settings instanceof Error) throw settings; + return settings; + }, mkdirSync() {}, appendFileSync() {}, + }; + return mainRequire(id); + }, + process: { argv: [], env: { LH_DEV_URL: "http://127.0.0.1:9000" }, platform: "darwin", on() {} }, + __dirname: path.dirname(mainFile), URL, + setInterval: (callback: () => void, delay: number) => intervals.push({ callback, delay }), + setTimeout() {}, + fixtureChild: { kill: (signal: string) => order.push(signal) }, + }); + runInContext(mainSource, context, { filename: mainFile }); + const run = (code: string) => runInContext(code, context); + return { + run, updater, windows, intervals, handles, settingsReads, order, app, + setSettings: (value: unknown) => { settings = JSON.stringify(value); }, + setSettingsFailure: (value: string | Error) => { settings = value; }, + setEnumerationFailure: (value: boolean) => { enumerationFails = value; }, + window: () => new FakeWindow(), + state: () => JSON.parse(JSON.stringify(run("updateState"))), + check: async (manual = false) => { run(`checkForUpdates({ manual: ${manual} })`); await Promise.resolve(); await Promise.resolve(); }, + menuCheck: () => menu[0]!.submenu!.find(item => item.label === "Check for Updates…")!.click!(), + }; +} + +describe("automatic-update preference gate", () => { + it("explicit OFF skips automatic checks, but manual checks still run without reading the preference", async () => { + const f = fixture(); + f.setSettings({ general: { autoUpdate: false } }); + f.run("setupAutoUpdater()"); + await f.check(); + assert.equal(f.updater.checks, 0); + assert.deepEqual(f.settingsReads, ["/fixture/custom-user-data-dev/data/settings.json"]); + await f.check(true); + assert.equal(f.updater.checks, 1); + assert.equal(f.settingsReads.length, 1); + assert.equal(f.updater.autoDownload, true); + assert.equal(f.updater.autoInstallOnAppQuit, true); + }); + + for (const [label, setting] of [ + ["ON", { general: { autoUpdate: true } }], ["absent field", { general: {} }], + ["absent section", { version: 1 }], ["null", null], ["non-boolean", { general: { autoUpdate: "false" } }], + ] as const) { + it(`${label} keeps automatic checks enabled`, async () => { + const f = fixture(); f.setSettings(setting); f.run("setupAutoUpdater()"); await f.check(); + assert.equal(f.updater.checks, 1); + }); + } + for (const [label, failure] of [["missing", new Error("ENOENT")], ["unreadable", new Error("EACCES")], ["corrupt", "{nope"]] as const) { + it(`${label} settings keep automatic checks enabled`, async () => { + const f = fixture(); f.setSettingsFailure(failure); f.run("setupAutoUpdater()"); await f.check(); + assert.equal(f.updater.checks, 1); + }); + } + + it("re-reads the setting on each check, without triggering a check merely when saved", async () => { + const f = fixture(); f.run("setupAutoUpdater()"); + f.setSettings({ general: { autoUpdate: false } }); await f.check(); + f.setSettings({ general: { autoUpdate: true } }); + assert.equal(f.updater.checks, 0); + await f.check(); + f.setSettings({ general: { autoUpdate: false } }); await f.check(); + assert.equal(f.updater.checks, 1); + assert.equal(f.settingsReads.length, 3); + }); + + for (const label of ["unpackaged", "missing updater"] as const) { + it(`${label} stays unavailable, not a saved OFF preference`, async () => { + const f = fixture({ packaged: label !== "unpackaged", updaterMissing: label === "missing updater" }); + f.run("setupAutoUpdater()"); await f.check(true); await f.check(); + assert.equal(f.updater.checks, 0); + assert.equal(f.settingsReads.length, 0); + assert.equal(f.state().status, "disabled"); + }); + } +}); + +describe("status observation never controls the updater", () => { + it("reports events, but a staged update stays visible without suppressing future checks", async () => { + const f = fixture(); const win = f.window(); f.run("setupAutoUpdater()"); + f.updater.emit("checking-for-update"); assert.equal(f.state().status, "checking"); + f.updater.emit("update-available", { version: "0.3.8" }); + assert.equal(f.state().status, "downloading"); assert.equal(f.state().version, "0.3.8"); + f.updater.emit("update-downloaded", { version: "0.3.8" }); + const ready = f.state(); + await f.check(); + f.updater.emit("error", new Error("failure with sensitive diagnostic details")); + assert.equal(f.updater.checks, 1); + assert.deepEqual(f.state(), ready); + f.updater.emit("update-downloaded", { version: "0.3.9" }); + assert.equal(f.state().version, "0.3.9"); + assert.ok(f.state().revision > ready.revision); + assert.equal(win.webContents.messages.at(-1)!.channel, "lh:update-status"); + }); + + for (const status of ["checking", "downloading", "error", "disabled", "downloaded"]) { + it(`a ${status} display state does not block a future automatic check`, async () => { + const f = fixture(); f.run(`setupAutoUpdater(); reportUpdateStatus("${status}")`); await f.check(); + assert.equal(f.updater.checks, 1); + }); + } + + it("check failures settle to a generic error and later checks still run", async () => { + const f = fixture(); f.run("setupAutoUpdater()"); + f.updater.failure = new Error("private endpoint/diagnostic detail"); await f.check(); + assert.equal(f.state().status, "error"); + assert.ok(!JSON.stringify(f.state()).includes("private")); + f.updater.failure = null; await f.check(); + assert.equal(f.updater.checks, 2); assert.equal(f.state().status, "up-to-date"); + }); + + it("a declined check makes no up-to-date claim", async () => { + const f = fixture(); f.updater.result = null; f.run("setupAutoUpdater()"); await f.check(true); + assert.equal(f.state().status, "idle"); + }); + + it("window enumeration/send failures do not break checks or the next window's notification", async () => { + const f = fixture(); const bad = f.window(); bad.webContents.fails = true; const good = f.window(); + f.run("setupAutoUpdater()"); await f.check(); + assert.equal(good.webContents.messages.at(-1)!.channel, "lh:update-status"); + f.setEnumerationFailure(true); await f.check(); + f.setEnumerationFailure(false); await f.check(); + assert.equal(f.updater.checks, 3); + }); + + it("OFF does not cancel in-flight events or alter staging/install-on-quit; Restart kills server first", async () => { + const f = fixture(); f.run("setupAutoUpdater()"); + f.updater.emit("update-available", { version: "0.3.8" }); + f.setSettings({ general: { autoUpdate: false } }); + f.updater.emit("update-downloaded", { version: "0.3.8" }); await f.check(); + assert.equal(f.state().status, "downloaded"); assert.equal(f.updater.checks, 0); + assert.equal(f.updater.autoDownload, true); assert.equal(f.updater.autoInstallOnAppQuit, true); + f.run("serverProcess = fixtureChild; installUpdateNow()"); + assert.deepEqual(f.order, ["SIGTERM", "install"]); + }); +}); + +describe("startup, timer, trusted IPC and menu wiring", () => { + it("starts and retains the 24h timer without any Settings subscription, checking current preference each time", async () => { + const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); + await f.run("main()"); + assert.equal(f.updater.checks, 0); + assert.equal(f.intervals.length, 1); assert.equal(f.intervals[0]!.delay, 24 * 60 * 60 * 1000); + f.setSettings({ general: { autoUpdate: true } }); f.intervals[0]!.callback(); await Promise.resolve(); + assert.equal(f.updater.checks, 1); + f.windows.length = 0; f.intervals[0]!.callback(); await Promise.resolve(); + assert.equal(f.updater.checks, 2, "checks also work with every window closed"); + }); + + it("ON/default boot checks immediately and status never disables the scheduled check", async () => { + const f = fixture(); f.setSettings({ version: 1 }); await f.run("main()"); + assert.equal(f.updater.checks, 1); + f.run('reportUpdateStatus("downloading", "0.3.8")'); f.intervals[0]!.callback(); await Promise.resolve(); + assert.equal(f.updater.checks, 2); + }); + + it("snapshot/manual IPC accepts only an owned main frame and snapshot reads never check", async () => { + const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()"); + const sender = f.windows[0]!.webContents; + const event = { sender, senderFrame: sender.mainFrame }; + const get = f.handles.get("lh:update:get")!; const check = f.handles.get("lh:update:check")!; + assert.equal((get(event) as { status: string }).status, "idle"); assert.equal(f.updater.checks, 0); + check(event); assert.equal(f.updater.checks, 1); + for (const bad of [ + { sender: {}, senderFrame: sender.mainFrame }, + { sender, senderFrame: { url: sender.mainFrame.url } }, + ]) { + assert.throws(() => get(bad), /Untrusted/); assert.throws(() => check(bad), /Untrusted/); + } + sender.mainFrame.url = "https://untrusted.invalid/"; + assert.throws(() => check(event), /Untrusted/); + assert.equal(f.updater.checks, 1); + }); + + it("menu restores/focuses a minimized window, opens General, and manually checks while OFF", async () => { + const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()"); + const win = f.windows[0]!; win.webContents.loading = false; win.minimized = true; + f.menuCheck(); + assert.equal(win.minimized, false); assert.equal(win.focused, true); + assert.ok(win.webContents.messages.some(message => message.channel === "lh:open-settings" && message.value === "general")); + assert.equal(f.updater.checks, 1); + }); + + it("menu reopens a window and delivers General after load; loading existing windows are covered too", async () => { + const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()"); + f.windows.length = 0; f.menuCheck(); + assert.equal(f.windows.length, 1); assert.equal(f.updater.checks, 1); + const win = f.windows[0]!; + assert.ok(!win.webContents.messages.some(message => message.channel === "lh:open-settings")); + win.webContents.emit("did-finish-load"); + assert.ok(win.webContents.messages.some(message => message.channel === "lh:open-settings")); + f.menuCheck(); win.webContents.emit("did-finish-load"); + assert.equal(win.webContents.messages.filter(message => message.channel === "lh:open-settings").length, 2); + }); + + it("menu navigation failure does not prevent its manual check", async () => { + const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()"); + f.windows[0]!.webContents.fails = true; f.windows[0]!.webContents.loading = false; + f.menuCheck(); assert.equal(f.updater.checks, 1); + }); +}); diff --git a/tests/desktop-platform.test.ts b/tests/desktop-platform.test.ts index c72e633..040a47a 100644 --- a/tests/desktop-platform.test.ts +++ b/tests/desktop-platform.test.ts @@ -72,18 +72,52 @@ describe("desktop window chrome", () => { bridge = api; }, }, - ipcRenderer: {}, + ipcRenderer: { on() {} }, }), }; runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context); assert.equal(bridge.platform, "win32"); assert.deepEqual(Object.keys(bridge).sort(), [ - "chatgpt", "expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform", "subscriptions", + "chatgpt", "checkForUpdates", "expandWindow", "getUpdateState", "getZoom", "installUpdate", "onOpenSettings", "onUpdateStatus", "onZoom", "platform", "subscriptions", ]); assert.deepEqual(Object.keys(bridge.subscriptions as object).sort(), ["cancel", "consent", "disconnect", "login", "onChange", "open", "respond", "retry", "status"]); }); }); +describe("update preload bridge", () => { + it("buffers the menu request, unsubscribes safely, and uses the matching snapshot/check channels", async () => { + let bridge!: { + onOpenSettings: (cb: (section: string) => void) => () => void; + getUpdateState: () => Promise; + checkForUpdates: () => Promise; + }; + const handlers = new Map void>(); + const invoked: string[] = []; + runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), { + process: { platform: "darwin" }, + require: () => ({ + contextBridge: { exposeInMainWorld: (_: string, value: typeof bridge) => { bridge = value; } }, + ipcRenderer: { + on: (channel: string, listener: (event: unknown, value: string) => void) => handlers.set(channel, listener), + invoke: async (channel: string) => { invoked.push(channel); }, + }, + }), + }); + const send = () => handlers.get("lh:open-settings")!({}, "general"); + const seen: string[] = []; + send(); const off = bridge.onOpenSettings(section => seen.push(section)); + assert.deepEqual(seen, ["general"], "pre-subscription request is delivered once"); + send(); assert.deepEqual(seen, ["general", "general"]); + off(); send(); assert.equal(seen.length, 2); + const next: string[] = []; const offNext = bridge.onOpenSettings(section => next.push(section)); + assert.deepEqual(next, ["general"]); + off(); send(); assert.equal(next.length, 2, "old cleanup cannot remove a newer subscriber"); + offNext(); + await bridge.getUpdateState(); await bridge.checkForUpdates(); + assert.deepEqual(invoked, ["lh:update:get", "lh:update:check"]); + }); +}); + describe("desktop packaging policy", () => { const yaml = require("js-yaml") as { load(source: string): Record }; const config = yaml.load(readFileSync(new URL("../electron-builder.yml", import.meta.url), "utf8")); diff --git a/tests/render-update-banner.test.tsx b/tests/render-update-banner.test.tsx index 0a9c307..a7207df 100644 --- a/tests/render-update-banner.test.tsx +++ b/tests/render-update-banner.test.tsx @@ -1,7 +1,7 @@ /** * Update-banner render tests (Wave C, 2026-10-10): the banner is a pure - * function of the last lh:update-status push — hidden without one (the - * browser/dev world: no window.lh at all), visible with the downloaded + * function of the shell's observational status — hidden without a bridge + * or snapshot/event, visible with the downloaded * status, Restart wired to the bridge. The subscribe effect only runs * client-side, so the SSR pass pins the hidden state; the pushed state is * exercised by mounting with the bridge stubbed (classic-JSX shim as in @@ -17,6 +17,7 @@ import * as ReactDOMClient from "react-dom/client"; import { flushSync } from "react-dom"; import { parseHTML } from "linkedom"; import UpdateBanner from "../components/update-banner.tsx"; +import type { UpdateState } from "../components/settings/update-settings.tsx"; // linkedom as the DOM for the interactive pass (first createRoot test in // the repo — the banner's push→render→click flow needs real effects). @@ -36,9 +37,9 @@ describe("update banner (Wave C)", () => { }); it("renders nothing when the bridge exists but no status has arrived", () => { - const listeners: Array<(s: { status: string; version: string | null }) => void> = []; + const listeners: Array<(s: UpdateState) => void> = []; (window as { lh?: unknown }).lh = { - onUpdateStatus: (cb: (s: { status: string; version: string | null }) => void) => { + onUpdateStatus: (cb: (s: UpdateState) => void) => { listeners.push(cb); return () => {}; }, @@ -50,10 +51,10 @@ describe("update banner (Wave C)", () => { }); it("shows the version + Restart once the downloaded push arrives, and Restart installs", async () => { - let pushed: ((s: { status: string; version: string | null }) => void) | null = null; + let pushed: ((s: UpdateState) => void) | null = null; let installed = 0; (window as { lh?: unknown }).lh = { - onUpdateStatus: (cb: (s: { status: string; version: string | null }) => void) => { + onUpdateStatus: (cb: (s: UpdateState) => void) => { pushed = cb; return () => {}; }, @@ -68,7 +69,7 @@ describe("update banner (Wave C)", () => { assert.equal(host.innerHTML, "", "hidden before the push"); await new Promise((r) => setTimeout(r, 0)); - flushSync(() => pushed?.({ status: "downloaded", version: "0.3.1" })); + flushSync(() => pushed?.({ status: "downloaded", version: "0.3.1", revision: 1 })); assert.ok(host.innerHTML.includes("0.3.1"), "the version renders"); assert.ok(host.innerHTML.includes("Restart"), "the restart affordance renders"); diff --git a/tests/render-update-settings.test.tsx b/tests/render-update-settings.test.tsx new file mode 100644 index 0000000..7c01467 --- /dev/null +++ b/tests/render-update-settings.test.tsx @@ -0,0 +1,223 @@ +/** Update UI with a fake preload bridge: no Electron, network or updates. */ +import React, { act } from "react"; +(globalThis as Record).React = React; +(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; + +import assert from "node:assert/strict"; +import { describe, it, afterEach } from "node:test"; +import { readFileSync } from "node:fs"; +import { renderToStaticMarkup } from "react-dom/server"; +import { createRoot, type Root } from "react-dom/client"; +import { parseHTML } from "linkedom"; +import { UpdateSettings, updateStatusText, MANUAL_UPDATE_HINT, AUTO_UPDATE_HINT, type UpdateState } from "../components/settings/update-settings.tsx"; +import UpdateBanner from "../components/update-banner.tsx"; +import { SettingsDialogBody, SettingsDialogProvider } from "../components/settings/settings-dialog.tsx"; +import nextConfig from "../next.config.ts"; + +const dom = parseHTML(""); +Object.assign(globalThis, { window: dom.window, document: dom.document, CustomEvent: dom.window.CustomEvent }); +const originalFetch = globalThis.fetch; +const roots: { root: Root; host: HTMLElement }[] = []; +const state = (over: Partial = {}): UpdateState => ({ status: "idle", version: null, revision: 0, ...over }); +const props = { autoUpdate: true, onAutoUpdateChange: () => {}, version: "0.3.7", homepage: "https://github.com/amosblomqvist/learnharness" }; +const q = (host: ParentNode, id: string) => host.querySelector(`[data-testid='${id}']`); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +function bridge(initial = state(), snapshot = Promise.resolve(initial)) { + const listeners = new Set<(value: UpdateState) => void>(); + const calls = { check: 0, install: 0, get: 0 }; + let onOpen: ((section: string) => void) | null = null; + let checkFailure: Error | null = null; + window.lh = { + expandWindow() {}, + getUpdateState: () => { calls.get++; return snapshot; }, + onUpdateStatus: cb => { listeners.add(cb); return () => { listeners.delete(cb); }; }, + checkForUpdates: async () => { calls.check++; if (checkFailure) throw checkFailure; }, + installUpdate: () => { calls.install++; }, + onOpenSettings: cb => { onOpen = cb; return () => { onOpen = null; }; }, + }; + return { + calls, listeners, + failCheck: () => { checkFailure = new Error("sensitive raw diagnostics"); }, + push: async (value: UpdateState) => { await act(async () => { for (const listener of listeners) listener(value); }); }, + open: async () => { await act(async () => onOpen?.("general")); }, + }; +} + +async function mount(element: React.ReactElement) { + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); roots.push({ root, host }); + await act(async () => { root.render(element); }); + return host; +} + +async function click(element: HTMLElement | null) { + assert.ok(element, "missing click target"); + await act(async () => { element.click(); }); +} + +afterEach(async () => { + for (const { root, host } of roots.splice(0)) { + await act(async () => { root.unmount(); }); host.remove(); + } + delete window.lh; + globalThis.fetch = originalFetch; +}); + +describe("version and contextual copy", () => { + it("build metadata comes from package.json and exposes only public version/homepage values", () => { + const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); + assert.deepEqual(nextConfig.env, { LH_VERSION: pkg.version, LH_HOMEPAGE: pkg.homepage }); + }); + + it("never claims live/latest information and keeps updater errors generic", () => { + assert.equal(updateStatusText(null), null); + assert.equal(updateStatusText(state()), null); + assert.equal(updateStatusText(state({ status: "disabled" })), null); + assert.equal(updateStatusText(state({ status: "checking" })), "Checking for updates…"); + assert.equal(updateStatusText(state({ status: "downloading", version: "0.3.8" })), "Downloading version 0.3.8…"); + assert.equal(updateStatusText(state({ status: "up-to-date" })), "No newer version was found at the last check."); + assert.equal(updateStatusText(state({ status: "downloaded", version: "0.3.8" })), "Version 0.3.8 is ready. It will install when you quit the app."); + assert.equal(updateStatusText(state({ status: "error" })), "Couldn’t check for updates. Try again later."); + assert.equal(updateStatusText(state({ status: "downloading" })), "Downloading an update…"); + assert.equal(updateStatusText(state({ status: "downloaded" })), "An update is ready. It will install when you quit the app."); + }); +}); + +describe("General update rows", () => { + it("browser/SSR shows version and installed-version release notes, without inactive controls or claims", () => { + const html = renderToStaticMarkup(); + assert.ok(html.includes("Version 0.3.7")); + assert.ok(html.includes(`${props.homepage}/releases/tag/v0.3.7`)); + assert.ok(!html.includes("update-check")); assert.ok(!html.includes("row-auto-update")); + assert.ok(!html.includes(MANUAL_UPDATE_HINT)); assert.ok(!html.includes("update-status")); + }); + + it("unpackaged/unavailable updater shows the version-only surface", async () => { + const b = bridge(state({ status: "disabled" })); const host = await mount(); + assert.ok(q(host, "row-version")); assert.equal(q(host, "update-check"), null); + assert.equal(q(host, "row-auto-update"), null); assert.equal(b.calls.check, 0); + }); + + it("idle shows two rows and clear consequences; opening Settings never checks", async () => { + const b = bridge(); const seen: boolean[] = []; + const host = await mount( seen.push(value)} />); + assert.deepEqual([...host.children].map(el => el.getAttribute("data-testid")), ["row-version", "row-auto-update"]); + assert.ok(q(host, "row-version")!.contains(q(host, "update-check"))); + assert.ok(host.textContent!.includes(MANUAL_UPDATE_HINT)); assert.ok(host.textContent!.includes(AUTO_UPDATE_HINT)); + assert.equal(q(host, "update-status"), null); assert.equal(b.calls.check, 0); + await click(q(host, "toggle-auto-update")); assert.deepEqual(seen, [false]); + await click(q(host, "update-check")); assert.equal(b.calls.check, 1); + }); + + it("manual check remains available while automatic checks are OFF", async () => { + const b = bridge(); const host = await mount(); + assert.equal(q(host, "toggle-auto-update")!.getAttribute("aria-checked"), "false"); + await click(q(host, "update-check")); assert.equal(b.calls.check, 1); + }); + + it("checking/downloading shows context and disables only the UI button; ready offers Restart", async () => { + const b = bridge(); const host = await mount(); + await b.push(state({ status: "checking", revision: 1 })); + assert.equal((q(host, "update-check") as HTMLButtonElement).disabled, true); + assert.equal(q(host, "update-check")!.getAttribute("aria-busy"), "true"); + assert.equal(q(host, "update-status")!.textContent, "Checking for updates…"); + await b.push(state({ status: "downloading", version: "0.3.8", revision: 2 })); + assert.equal(q(host, "update-status")!.textContent, "Downloading version 0.3.8…"); + assert.equal((q(host, "update-check") as HTMLButtonElement).disabled, true); + await b.push(state({ status: "downloaded", version: "0.3.8", revision: 3 })); + assert.equal(q(host, "update-check"), null); assert.ok(q(host, "update-status")!.textContent!.includes("quit the app")); + await click(q(host, "update-restart")); assert.equal(b.calls.install, 1); + }); + + it("up-to-date and failure statuses allow another manual check", async () => { + const b = bridge(); const host = await mount(); + await b.push(state({ status: "up-to-date", revision: 1 })); + assert.equal(q(host, "update-status")!.textContent, "No newer version was found at the last check."); + await b.push(state({ status: "error", revision: 2 })); + assert.equal((q(host, "update-check") as HTMLButtonElement).disabled, false); + assert.equal(q(host, "update-status")!.textContent, "Couldn’t check for updates. Try again later."); + await click(q(host, "update-check")); assert.equal(b.calls.check, 1); + }); + + it("bridge command rejection is handled without exposing raw diagnostics", async () => { + const b = bridge(); b.failCheck(); const host = await mount(); + await click(q(host, "update-check")); + assert.equal(q(host, "update-status")!.textContent, "Couldn’t check for updates. Try again later."); + assert.ok(!host.textContent!.includes("sensitive")); + }); + + it("late Settings and banner subscribers recover the staged update without an event", async () => { + const b = bridge(state({ status: "downloaded", version: "0.3.8", revision: 5 })); + const host = await mount(<>); + assert.ok(q(host, "update-restart")); assert.ok(q(host, "update-banner")); + assert.ok(q(host, "update-banner")!.textContent!.includes("0.3.8")); + await click(q(host, "update-banner")!.querySelector("button")); assert.equal(b.calls.install, 1); + assert.equal(b.calls.check, 0); + }); + + it("a delayed older snapshot cannot overwrite a newer push in Settings or the banner", async () => { + const snapshot = deferred(); const b = bridge(state(), snapshot.promise); + const host = await mount(<>); + await b.push(state({ status: "downloaded", version: "0.3.8", revision: 4 })); + await act(async () => { snapshot.resolve(state({ status: "checking", revision: 3 })); }); + assert.ok(q(host, "update-restart")); assert.ok(q(host, "update-banner")); + }); + + it("snapshot rejection cannot erase an already received event and late callbacks are unsubscribed", async () => { + const snapshot = deferred(); const b = bridge(state(), snapshot.promise); + const host = await mount(); + await b.push(state({ status: "downloaded", version: "0.3.8", revision: 1 })); + await act(async () => { snapshot.reject(new Error("IPC disconnected")); }); + assert.ok(q(host, "update-restart")); + const { root } = roots.pop()!; await act(async () => { root.unmount(); }); host.remove(); + assert.equal(b.listeners.size, 0); + }); + + it("snapshot rejection without an event is handled with a generic message", async () => { + const snapshot = deferred(); bridge(state(), snapshot.promise); + const host = await mount(); + await act(async () => { snapshot.reject(new Error("sensitive error")); }); + assert.equal(q(host, "update-status")!.textContent, "Couldn’t check for updates. Try again later."); + assert.ok(!host.textContent!.includes("sensitive")); + }); +}); + +describe("Settings integration", () => { + it("the toggle sends the complete General section and follows stored server truth", async () => { + bridge(); + // A synthetic future sibling exercises the caller's section-preserving + // contract without introducing a new product setting or changing its API. + const record = { version: 1, general: { autoUpdate: true, futureSibling: "keep" } }; + const puts: unknown[] = []; + globalThis.fetch = async (input, init) => { + assert.equal(String(input), init?.method === "PUT" ? "/api/settings/general" : "/api/settings"); + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)); puts.push(body); + return Response.json({ settings: { ...record, general: body } }); + } + return Response.json({ settings: record }); + }; + const host = await mount( {}} />); + await click(q(host, "toggle-auto-update")); + assert.deepEqual(puts, [{ autoUpdate: false, futureSibling: "keep" }]); + assert.equal(q(host, "toggle-auto-update")!.getAttribute("aria-checked"), "false"); + }); + + it("the shell's navigation request opens General, but opening alone never checks", async () => { + const b = bridge(); + globalThis.fetch = async () => Response.json({ settings: { version: 1 } }); + const host = await mount(app); + assert.equal(q(document, "settings-dialog"), null); + await b.open(); + assert.equal(q(document, "settings-nav-general")!.getAttribute("aria-current"), "true"); + assert.ok(q(document, "row-version")); assert.equal(b.calls.check, 0); + assert.ok(host.textContent!.includes("app")); + }); +}); diff --git a/tests/tooltip-mechanism.test.tsx b/tests/tooltip-mechanism.test.tsx index 653cfe6..b83d834 100644 --- a/tests/tooltip-mechanism.test.tsx +++ b/tests/tooltip-mechanism.test.tsx @@ -17,7 +17,7 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { Hint, hintText } from "../components/ui/hint.tsx"; import { TOOLTIP_DELAY_MS } from "../components/ui/tooltip.tsx"; -import { AUTO_UPDATE_HINT } from "../components/settings/settings-dialog.tsx"; +import { AUTO_UPDATE_HINT } from "../components/settings/update-settings.tsx"; import { IS_MAC } from "../src/core/keyboard.ts"; describe("hintText — the parenthesized composition", () => { @@ -46,7 +46,7 @@ describe("subtlety round pins (owner 2026-09-25)", () => { describe("spot-pins: the dev-speak cull (owner copy review at merge)", () => { it("the auto-update hint is honest user language — no stream jargon", () => { - assert.equal(AUTO_UPDATE_HINT, "Automatic updates arrive in an upcoming release"); + assert.equal(AUTO_UPDATE_HINT, "Check at startup and every 24 hours. Turning this off does not stop updates already in progress or ready to install."); assert.ok(!AUTO_UPDATE_HINT.includes("wave"), "no stream vocabulary"); }); }); From 7d65fa87544a47228ae46edc324327cf7c09d7bc Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sun, 25 Oct 2026 19:45:17 +0200 Subject: [PATCH 17/87] fix: recover session submissions and failed turn settlement --- app/api/chats/[id]/route.ts | 4 +- app/api/chats/[id]/stream/route.ts | 7 +- app/chat/[id]/page.tsx | 3 +- components/chat.tsx | 52 +++-- components/question-dock.tsx | 33 ++- components/session-core.ts | 201 +++++++++++++++--- components/session.ts | 14 +- docs/architecture.md | 4 +- src/core/question-core.ts | 15 ++ src/server/chat-handler.ts | 42 +++- src/server/chats.ts | 3 +- src/server/pi/ui-stream.ts | 35 ++- src/server/turn-liveness.ts | 9 +- src/server/turn-runs.ts | 35 ++- src/server/turn-service.ts | 17 +- tests/chat-flow.test.ts | 104 ++++++++- tests/graph.test.ts | 6 +- tests/render-cards.test.tsx | 25 +++ tests/session-core.test.ts | 328 +++++++++++++++++++++++++++++ tests/turn-liveness.test.ts | 11 + tests/turn-recovery.test.ts | 90 ++++++++ 21 files changed, 944 insertions(+), 94 deletions(-) create mode 100644 tests/turn-recovery.test.ts diff --git a/app/api/chats/[id]/route.ts b/app/api/chats/[id]/route.ts index 1b19ce5..33f7e04 100644 --- a/app/api/chats/[id]/route.ts +++ b/app/api/chats/[id]/route.ts @@ -1,4 +1,4 @@ -import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, sessionCost, stores, type ChatDeleteMode } from "@/src/server"; +import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, sessionCost, stores, turnFailure, type ChatDeleteMode } from "@/src/server"; export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -19,6 +19,8 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str messages: chat.messages, pricing: sessionCost(chat), turnInFlight: isTurnRunning(id), + // Also reaches idle views that missed the failing turn's stream. + turnError: turnFailure(id), compacting: compactionInFlight(id), coverage: sessionCoverage(id), }); diff --git a/app/api/chats/[id]/stream/route.ts b/app/api/chats/[id]/stream/route.ts index 6dcc08f..7c56152 100644 --- a/app/api/chats/[id]/stream/route.ts +++ b/app/api/chats/[id]/stream/route.ts @@ -5,8 +5,9 @@ * submitter too attaches here. One SSE stream per chat: a running turn → * subscribe to the live turn object: FULL replay of the event log, then * live chunks until settle ([DONE]). No running turn → the cheap terminal - * ({"type":"turn-settled"} + [DONE]): the record is current, the client - * refetches it. + * ({"type":"turn-settled"} + [DONE]): the client refetches the record. + * A retained settlement-failure notice precedes that terminal when a + * reply could not be saved before the submitter attached. * * 404 mirrors the POST handler's unknown-chat. Framework route is a thin * wrapper over the server barrel (the import law); tests drive the seams @@ -18,6 +19,6 @@ export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> const { id } = await ctx.params; if (!getChat(id)) return Response.json({ error: "Unknown chat" }, { status: 404 }); const handle = runningTurn(id); - if (!handle) return settledTurnResponse(); + if (!handle) return settledTurnResponse(id); return piTurnResponse(handle); } diff --git a/app/chat/[id]/page.tsx b/app/chat/[id]/page.tsx index 9f35d80..83413b3 100644 --- a/app/chat/[id]/page.tsx +++ b/app/chat/[id]/page.tsx @@ -1,7 +1,7 @@ import { notFound } from "next/navigation"; import { Chat } from "@/components/chat"; import { slotsConfigured } from "@/src/core"; -import { compactionInFlight, getChat, isTurnRunning, readSettingsRecord } from "@/src/server"; +import { compactionInFlight, getChat, isTurnRunning, readSettingsRecord, turnFailure } from "@/src/server"; export const dynamic = "force-dynamic"; @@ -29,6 +29,7 @@ export default async function ChatPage({ // derived app-side (core/view.ts projectView; GET serves the same // native messages). initialMessages={chat.messages} + initialError={turnFailure(chat.id)} initialConfigured={slotsConfigured(record?.models)} initialPrompt={prompt} // A teacher turn that outlived its client: the remounted view must diff --git a/components/chat.tsx b/components/chat.tsx index 4986f3f..f8866f1 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -38,7 +38,6 @@ import { messageAnchorId, textPartMessageId, kbdEventForAction as kbdEventForChat, - needsContinuation, type ChatInput, type ChatSettings, type LhMessage, @@ -57,7 +56,7 @@ import { PracticeDock } from "@/components/practice-dock"; import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards"; import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card"; import { TopBar } from "@/components/top-bar"; -import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage, SessionPricing } from "@/src/core"; +import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, QuestionFormDraft, TurnUsage, SessionPricing } from "@/src/core"; import type { LhViewMessage } from "@/src/core"; import { PhaseChip, PhaseDivider } from "@/components/phase-divider"; import { @@ -198,6 +197,7 @@ export function Chat({ chatId, title, initialMessages, + initialError, initialPrompt, initiallyInFlight, initiallyCompacting = false, @@ -207,6 +207,8 @@ export function Chat({ chatId: string; title: string; initialMessages: LhMessage[]; + /** Memory-only settlement notice supplied on reopening. */ + initialError?: string; /** The session kind (practice sessions, owner 2026-10-04): practice * renders no phase chip (no probe→teach arc applies) and a Practice * tag rides the header instead. */ @@ -236,8 +238,8 @@ export function Chat({ // (core/view.ts projectView); sends are ONE explicit input event each // ({chatId, input} — the stash indirection died with the SDK), and the // turn stream is the VERBATIM AgentEvent passthrough the server emits. - const { messages, nativeMessages, contextTokens, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } = - useChatSession({ chatId, initialMessages, initiallyInFlight }); + const { messages, nativeMessages, contextTokens, send, continueIfOwed, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, questionDraft, saveQuestionDraft, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } = + useChatSession({ chatId, initialMessages, initialError, initiallyInFlight }); const sessionFindScopeRef = useRef(null); const scrollContextRef = useRef(null); @@ -434,9 +436,7 @@ export function Chat({ useEffect(() => { if (mountContinuation.current) return; mountContinuation.current = true; - if (!turnInFlight && status === "ready" && needsContinuation(initialMessages)) { - void send({ type: "continue" }); - } + if (!turnInFlight) continueIfOwed(initialMessages); // mount-only by design (turnInFlight read at initial value) }, []); @@ -532,10 +532,12 @@ export function Chat({ }, []); const pendingQuestionId = pending?.toolCallId ?? null; useEffect(() => { - // A new pending question never inherits the previous one's draft. - readingThinkingRef.current = ""; - setReadingThinking(""); - }, [pendingQuestionId]); + // Different calls never share a draft; a failed submission of THIS + // call restores the reading channel alongside the form's snapshot. + const reading = pendingQuestionId ? questionDraft(pendingQuestionId)?.readingThinking ?? "" : ""; + readingThinkingRef.current = reading; + setReadingThinking(reading); + }, [pendingQuestionId, questionDraft]); // The phase at every message (one linear fold): the transcript draws a // color-coded dotted divider at each boundary; the header chip reads the @@ -717,20 +719,20 @@ export function Chat({ const syncFromServer = useCallback(async () => { const res = await fetch(`/api/chats/${chatId}`); if (!res.ok) return; - const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[]; pricing?: SessionPricing }; + const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[]; pricing?: SessionPricing; turnError?: string }; if (Array.isArray(record.turns)) setTurns(record.turns); setPricing(record.pricing); if (!Array.isArray(record.messages)) return; - setMessages(record.messages); + setMessages(record.messages, record.turnError); // The continuation seam (contracts.ts protocol §4): a job result // landing in the record leaves the agent owing a continuation — the // record ENDS on un-consumed results, the dock owns no question, and // nothing runs. The native fold (needsContinuation) is the old SDK // lastAssistantMessageIsCompleteWithToolCalls check. - if (status === "ready" && needsContinuation(record.messages)) { - void send({ type: "continue" }); - } - }, [chatId, setMessages, send, status]); + // Current model truth also guards retained/live failures: canonical + // sync must never automatically replace a reply that failed to save. + continueIfOwed(record.messages); + }, [chatId, setMessages, continueIfOwed]); // The settled terminal's other half (stream-reattach wave): the // transport resolved turn-settled — no turn running, the record is @@ -905,8 +907,8 @@ export function Chat({ // (endTurn emits from onFinish before the response terminates, so the // SSE frame reliably beats [DONE]) — the handler below cannot sync // there, it flags instead, and THIS flip consumes the flag: the record - // is guaranteed durable (endTurn fires only after persistTurn, the S2 - // ordering law), so replacing the view onto it absorbs the tail's + // reflects durable truth (a failed settlement retains its notice, + // not its unsaved reply), so replacing the view absorbs the tail's // streamed c_-id copies into base under their server ids. Without // this the tail kept those copies forever and the next send's // steer-adoption window rendered them a second time. The wake handoff @@ -1596,6 +1598,8 @@ export function Chat({ fold) and NEVER overlaps the composer. */} { if (pending) saveQuestionDraft(pending.toolCallId, { form }); }} practice={activeProblem} compactionRunning={compacting} onSubmitThinking={submitThinking} @@ -1663,8 +1667,10 @@ export function Chat({ // ride the same callback (QuestionAnswerData.dismissed). The // reading-thinking draft rides along (owner 2026-10-03) — the // check answer is its only carrier. + saveQuestionDraft(pending.toolCallId, { readingThinking: readingThinkingRef.current }); const reading = readingThinkingRef.current.trim() || undefined; - clearReadingThinking(); + // The pending-id effect clears this only when the question + // actually leaves. An offline preflight keeps the draft visible. submitAnswer(pending.tool, pending.toolCallId, reading ? { ...output, readingThinking: reading } : output); }} /> @@ -1821,6 +1827,8 @@ export function shouldStampGeometryMute(nextH: number, prevH: number, pending: b function DockOverlay({ pending, + initialQuestionDraft, + onSaveQuestionDraft, practice, compactionRunning, onSubmitThinking, @@ -1843,6 +1851,8 @@ function DockOverlay({ queuedSteers, }: { pending: ReturnType; + initialQuestionDraft?: QuestionFormDraft; + onSaveQuestionDraft: (draft: QuestionFormDraft) => void; /** Compaction running for this chat (round 14) — the docks' submit * affordance gate (the server's registry is the real one). */ compactionRunning: boolean; @@ -2309,6 +2319,8 @@ function DockOverlay({ phase={phase} onExpand={expandAndReveal} onSubmit={onSubmitAnswer} + initialDraft={initialQuestionDraft} + onSaveDraft={onSaveQuestionDraft} onEscape={dictationCancel} dictationClaim={!revealPending} compactionRunning={compactionRunning} diff --git a/components/question-dock.tsx b/components/question-dock.tsx index 3b6f848..b5cd734 100644 --- a/components/question-dock.tsx +++ b/components/question-dock.tsx @@ -28,6 +28,7 @@ import { type DockSelection, type ExplainToolInput, type QuestionAnswerData, + type QuestionFormDraft, type QuestionData, type QuestionMode, type QuizToolInput, @@ -71,6 +72,9 @@ export interface QuestionDockProps { reveal?: boolean; onExpand: () => void; onSubmit: (output: QuestionAnswerData) => void; + /** Restore only this call's unconfirmed submission after recovery. */ + initialDraft?: QuestionFormDraft; + onSaveDraft?: (draft: QuestionFormDraft) => void; /** Escape in a question field stops dictation (STT wave) — threaded * from the page that owns the dictation hook. */ onEscape?: () => void; @@ -100,6 +104,8 @@ export function QuestionDock({ reveal = false, onExpand, onSubmit, + initialDraft, + onSaveDraft, onEscape, phase = "probe", dictationClaim = true, @@ -155,6 +161,8 @@ export function QuestionDock({ phase={phase} active={expanded} onSubmit={onSubmit} + initialDraft={initialDraft} + onSaveDraft={onSaveDraft} onEscape={onEscape} dictationClaim={dictationClaim} compactionRunning={compactionRunning} @@ -250,6 +258,8 @@ function QuestionDockForm({ phase = "probe", active, onSubmit, + initialDraft, + onSaveDraft, onEscape, dictationClaim = true, compactionRunning = false, @@ -311,22 +321,22 @@ function QuestionDockForm({ // single-select displaces, multi-select coexists — picking the ask // "Other…" row in a multi-select must never clear already-picked // options, and picking an option must never clear a typed custom answer. - const [sel, setSel] = useState(emptyDockSelection()); + const [sel, setSel] = useState(() => initialDraft?.selection ?? emptyDockSelection()); // The learner's transfer channel — thinking, never "a note". - const [thinking, setThinking] = useState(""); - const [other, setOther] = useState(""); - const [text, setText] = useState(""); + const [thinking, setThinking] = useState(initialDraft?.thinking ?? ""); + const [other, setOther] = useState(initialDraft?.other ?? ""); + const [text, setText] = useState(initialDraft?.text ?? ""); /* Refs MIRROR the two dictation-target fields ("nothing gets lost" * round, owner 2026-10-01): the run's flush lands via setState, which * a closure waiting through `await finishDictation()` cannot observe * in time — submit reads these, kept current at every write site. */ - const thinkingTextRef = useRef(""); - const answerTextRef = useRef(""); + const thinkingTextRef = useRef(initialDraft?.thinking ?? ""); + const answerTextRef = useRef(initialDraft?.text ?? ""); // Confidence (owner, 2026-10-01): an index into CONFIDENCE_STOPS, // resting at 50 — a real coin flip by definition, untouched or // deliberate (the same number, the same meaning). The dock unmounts // between questions, so each question starts fresh at 50. - const [confIdx, setConfIdx] = useState(DEFAULT_CONFIDENCE_INDEX); + const [confIdx, setConfIdx] = useState(initialDraft?.confidenceIndex ?? DEFAULT_CONFIDENCE_INDEX); const selectMode: SelectMode = qd.mode === "multi-select" ? "multi-select" : "single-select"; // The PRIMARY dictation input (owner, STT round 1): while a question @@ -390,6 +400,15 @@ function QuestionDockForm({ // "untouched"); buildAnswer drops it on "I don't know". confidence: kind === "quiz" ? CONFIDENCE_STOPS[confIdx] : undefined, }; + // Snapshot before optimistic submission unmounts the form. Ref values + // include the dictation finals that just landed, unlike stale closures. + onSaveDraft?.({ + selection: { ...sel, selected: new Set(sel.selected) }, + thinking: thinkingTextRef.current, + other, + text: answerTextRef.current, + confidenceIndex: confIdx, + }); onSubmit(buildAnswer(qd, req, toolName)); }; diff --git a/components/session-core.ts b/components/session-core.ts index 657d0da..0ac5d3f 100644 --- a/components/session-core.ts +++ b/components/session-core.ts @@ -17,6 +17,8 @@ import type { AgentEvent } from "@earendil-works/pi-agent-core"; import type { ToolResultMessage } from "@earendil-works/pi-ai"; import { needsContinuation, + danglingQuestionOf, + answeredCallIds, measuredContextTokens, newAnswerResult, newNextMessage, @@ -30,6 +32,7 @@ import { type LhViewMessage, type LiveExecution, type QuestionAnswerData, + type QuestionDraft, } from "../src/core"; export type SessionStatus = "ready" | "submitted" | "streaming" | "error"; @@ -78,13 +81,25 @@ async function turnInFlight(model: SessionModel): Promise { } } -/** One wire event off the SSE body: parsed JSON, or the [DONE] terminal. */ -async function* sseEvents(reader: ReadableStreamDefaultReader): AsyncGenerator> { +const STREAM_DROPPED = "The connection dropped before the turn completed."; + +/** Mark transport failures without confusing JSON/application bugs with drops. */ +class StreamReadError extends Error { + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause), { cause }); + } +} + +/** Wire events; return true ONLY for the explicit completion marker. */ +async function* sseEvents(reader: ReadableStreamDefaultReader): AsyncGenerator, boolean> { const decoder = new TextDecoder(); let buf = ""; for (;;) { - const { done, value } = await reader.read(); - if (done) return; + let chunk: ReadableStreamReadResult; + try { chunk = await reader.read(); } + catch (err) { throw new StreamReadError(err); } + const { done, value } = chunk; + if (done) return false; buf += decoder.decode(value, { stream: true }); for (;;) { const end = buf.indexOf("\n\n"); @@ -94,7 +109,7 @@ async function* sseEvents(reader: ReadableStreamDefaultReader): Asyn const dataLine = event.split("\n").find((l) => l.startsWith("data: ")); if (!dataLine) continue; const data = dataLine.slice("data: ".length); - if (data === "[DONE]") return; + if (data === "[DONE]") return true; yield JSON.parse(data) as Record; } } @@ -196,6 +211,8 @@ export function visibleQueuedSteers( export interface SessionModelOptions extends SessionModelDeps { chatId: string; initialMessages: LhMessage[]; + /** Retained, memory-only server failure when reopening a session. */ + initialError?: string; /** Fires on every state change (the hook re-renders). */ onChange?: () => void; /** The reattach settled terminal (turn-settled): no turn running, the @@ -220,6 +237,9 @@ export class SessionModel { // at every full sync; the write-ahead absorbs the optimistic entries. private base: LhMessage[]; private tail: LhMessage[] = []; + private questionDrafts = new Map(); + /** Only learner answers submitted through send(), never silent dismissals. */ + private unconfirmedAnswers = new Map(); // The live turn's execution layer (view model mid-turn states): private executions = new Map(); private streamingIds = new Set(); @@ -229,6 +249,8 @@ export class SessionModel { private statusValue: SessionStatus = "ready"; /** Request/loop failures are transient; provider failures derive from messages. */ private errorValue: { message: string } | null = null; + /** Dismissing a banner must not authorize an automatic replacement turn. */ + private automaticContinuationBlocked = false; /** A completed failure from THIS consume, not the previous attempt's record. */ private liveFailureId: string | null = null; // The single live consume (POST response or reattach stream): a newer @@ -240,6 +262,8 @@ export class SessionModel { constructor(opts: SessionModelOptions) { this.chatId = opts.chatId; this.base = opts.initialMessages; + this.errorValue = opts.initialError === undefined ? null : { message: opts.initialError }; + this.automaticContinuationBlocked = opts.initialError !== undefined; // BOUND: the browser's native fetch is a Window method — storing it // unbound makes every call throw "Illegal invocation" in the browser // (Node's undici doesn't care, so the tests never saw it). Always go @@ -300,6 +324,7 @@ export class SessionModel { private setError(message: string | null): void { this.errorValue = message === null ? null : { message }; + if (message !== null) this.automaticContinuationBlocked = true; this.onChange?.(); } @@ -346,6 +371,12 @@ export class SessionModel { } const agent = event as unknown as AgentEvent; switch (agent.type) { + case "agent_start": + // Positive evidence of a new/replayed server-owned turn. Any + // later failure in that replay blocks continuation again. + this.automaticContinuationBlocked = false; + if (this.errorValue !== null) this.setError(null); + return false; case "message_start": case "message_update": case "message_end": { @@ -445,18 +476,28 @@ export class SessionModel { /** Consume one SSE body to its [DONE] terminal. The turn-settled * terminal (stream-route only) fires the settle hook and ends. */ - async consumeStream(res: Response, controller = this.consumeAbort): Promise { + async consumeStream(res: Response, controller = this.consumeAbort, allowRecovery = true): Promise { if (!res.body) throw new Error("The turn stream body is empty."); const reader = res.body.getReader(); let sawEvent = false; + let sawDone = false; try { - for await (const event of sseEvents(reader)) { + const events = sseEvents(reader); + for (;;) { + let next: IteratorResult, boolean>; + try { next = await events.next(); } + catch (err) { + if (err instanceof StreamReadError) break; + throw err; + } if (this.consumeAbort !== controller) return; + if (next.done) { sawDone = next.value; break; } + const event = next.value; if (event.type === "turn-settled") { // The raced-settle terminal: no turn to attach to — the // record is current; the settle hook refetches. - await reader.cancel().catch(() => {}); - if (this.consumeAbort === controller) this.onReattachSettled?.(); + sawDone = true; + this.onReattachSettled?.(); return; } const acted = this.applyWireEvent(event); @@ -466,14 +507,32 @@ export class SessionModel { } } } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); // A cancelled consume may finish after its replacement has entered // Thinking. Only the current owner can settle shared state. if (this.consumeAbort === controller) { this.streamingIds = new Set(); this.executions = new Map(); - if (this.statusValue !== "error") this.setStatus("ready"); + if (sawDone && this.statusValue !== "error") this.setStatus("ready"); } } + if (this.consumeAbort !== controller || sawDone) return; + if (!allowRecovery) throw new StreamReadError(new Error(STREAM_DROPPED)); + // One read-only recovery: refresh the canonical base, then replay the + // SAME server-owned turn. Never resend the learner's input. + await this.resumeStream(true, false); + } + + private async fetchView(url: string, signal: AbortSignal, recoveringDrop: boolean): Promise { + try { + const res = await this.fetch(url, { signal }); + if (recoveringDrop && !res.ok) throw new Error((await res.text()) || STREAM_DROPPED); + return res; + } catch (err) { + if (recoveringDrop && !signal.aborted) throw new StreamReadError(err); + throw err; + } } /** @@ -489,7 +548,7 @@ export class SessionModel { * lands on top. The stream then replays the turn from its first event * through the SAME consume path. */ - async resumeStream(refetchRecordFirst = true): Promise { + async resumeStream(refetchRecordFirst = true, allowRecovery = true): Promise { this.consumeAbort?.abort(); const controller = new AbortController(); this.consumeAbort = controller; @@ -519,16 +578,27 @@ export class SessionModel { this.setStatus("submitted"); try { if (refetchRecordFirst) { - const recordRes = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, { - signal: controller.signal, - }); + const recordRes = await this.fetchView(`/api/chats/${encodeURIComponent(this.chatId)}`, controller.signal, !allowRecovery); if (this.consumeAbort !== controller) return; if (recordRes.ok) { - const record = (await recordRes.json()) as { messages?: LhMessage[] }; + let text: string; + try { text = await recordRes.text(); } + catch (err) { + if (!allowRecovery && !controller.signal.aborted) throw new StreamReadError(err); + throw err; + } + // Parse outside the transport catch: malformed JSON is a + // contract bug, not a connection-drop diagnostic. + const record = JSON.parse(text) as { messages?: LhMessage[]; turnError?: string }; if (this.consumeAbort !== controller) return; if (Array.isArray(record.messages)) { this.mutate(() => { this.base = record.messages!; + if (record.turnError !== undefined) { + this.errorValue = { message: record.turnError }; + this.automaticContinuationBlocked = true; + } + this.forgetResolvedDrafts(record.messages!); this.incompleteUsageIds.clear(); this.tail = []; }); @@ -543,15 +613,19 @@ export class SessionModel { }); } } - const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, { - signal: controller.signal, - }); + const res = await this.fetchView(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, controller.signal, !allowRecovery); if (this.consumeAbort !== controller) return; if (!res.ok) throw new Error((await res.text()) || "Failed to attach to the running turn."); - await this.consumeStream(res, controller); + await this.consumeStream(res, controller, allowRecovery); } catch (err) { - if (this.consumeAbort !== controller || (err as Error).name === "AbortError") return; // a newer consume won - this.setError((err as Error).message); + if (this.consumeAbort !== controller || controller.signal.aborted) return; // intentionally cancelled + // A failed record read can leave the optimistic answer in the tail. + // Restore its Submit affordance; the next attempt reconciles first. + this.mutate(() => { + this.tail = this.tail.filter(m => !this.unconfirmedAnswers.has(m.id)); + this.unconfirmedAnswers.clear(); + }); + this.setError(!allowRecovery && err instanceof StreamReadError ? STREAM_DROPPED : (err as Error).message); this.setStatus("error"); } } @@ -563,12 +637,42 @@ export class SessionModel { * server's 409 + identity retry covers the wake-race window). */ async send(input: ChatInput): Promise { - if (!this.readyRef) return; + const recovering = this.statusValue === "error"; + if (!this.readyRef && !recovering) return; this.consumeAbort?.abort(); const controller = new AbortController(); this.consumeAbort = controller; this.setError(null); this.setStatus("submitted"); + if (recovering && input.type === "answer") { + try { + const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, { signal: controller.signal }); + if (this.consumeAbort !== controller) return; + if (!res.ok) throw new Error((await res.text()) || "An error occurred."); + const record = await res.json() as { messages?: LhMessage[]; turnInFlight?: boolean; turnError?: string }; + if (this.consumeAbort !== controller) return; + // No verified record/liveness: retain the draft and never guess + // whether a lost acknowledgement meant the input was accepted. + if (!Array.isArray(record.messages) || typeof record.turnInFlight !== "boolean") throw new Error("An error occurred."); + this.setMessages(record.messages, record.turnError); + if (record.turnInFlight) { + await this.resumeStream(false); + return; + } + if (input.type === "answer" && (answeredCallIds(record.messages).has(input.toolCallId) || danglingQuestionOf(record.messages)?.toolCallId !== input.toolCallId)) { + // The call already resolved, or no longer owns the dock. Adopt + // truth, never POST that answer a second time. + this.setStatus("ready"); + return; + } + } catch (err) { + if (this.consumeAbort !== controller || controller.signal.aborted) return; + this.setError((err as Error).message); + this.setStatus("error"); + return; + } + } + if (input.type !== "continue") this.automaticContinuationBlocked = false; // The optimistic appends — exactly the messages the server's // write-ahead will persist (inputAppends), with LOCAL ids so // adoption can tell them from the record's absorbed copies. @@ -585,7 +689,9 @@ export class SessionModel { optimistic.push({ ...newNextMessage(), id: localId() }); } else if (input.type === "answer") { const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz"; - optimistic.push({ ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() }); + const result = { ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() }; + optimistic.push(result); + this.unconfirmedAnswers.set(result.id, input.toolCallId); } if (optimistic.length > 0) this.mutate(() => { this.tail = [...this.tail, ...optimistic]; @@ -605,9 +711,7 @@ export class SessionModel { // connection is Diet A's whole job. const res = await this.postInputEvent(input); if (res.status === 409) { - this.setError("A turn is already running — try again in a moment."); - this.setStatus("error"); - return; + throw new Error("A turn is already running — try again in a moment."); } if (!res.ok) { // Refusal envelopes (503 BYOK/key gates) and 400s: the RAW body @@ -629,7 +733,17 @@ export class SessionModel { if (this.consumeAbort !== controller) return; await this.resumeStream(true); } catch (err) { - if ((err as Error).name === "AbortError") return; // stop() or a newer consume won + if (this.consumeAbort !== controller || controller.signal.aborted) return; + // Restore the question when acceptance failed or is unknown. Remove + // only THIS optimistic answer, never saved copies/newer live content. + // Other input kinds retain their existing optimistic presentation. + if (input.type === "answer") { + const ids = new Set(optimistic.map(m => m.id)); + this.mutate(() => { + this.tail = this.tail.filter(m => !ids.has(m.id)); + for (const id of ids) this.unconfirmedAnswers.delete(id); + }); + } this.setError((err as Error).message); this.setStatus("error"); } @@ -740,10 +854,32 @@ export class SessionModel { this.setStatus("ready"); } + /** Submission snapshots outlive the dock, not the session mount. */ + questionDraft(toolCallId: string): QuestionDraft | undefined { + return this.questionDrafts.get(toolCallId); + } + + saveQuestionDraft(toolCallId: string, draft: Partial): void { + this.questionDrafts.set(toolCallId, { ...this.questionDrafts.get(toolCallId), ...draft }); + } + + private forgetResolvedDrafts(record: readonly LhMessage[]): void { + const answered = answeredCallIds(record); + for (const id of answered) this.questionDrafts.delete(id); + for (const [id, callId] of this.unconfirmedAnswers) { + if (answered.has(callId)) this.unconfirmedAnswers.delete(id); + } + } + /** Full snapshot swap (the idle settle sync — server canon). */ - setMessages(record: LhMessage[]): void { + setMessages(record: LhMessage[], turnError?: string): void { this.mutate(() => { this.base = record; + if (turnError !== undefined) { + this.errorValue = { message: turnError }; + this.automaticContinuationBlocked = true; + } + this.forgetResolvedDrafts(record); this.incompleteUsageIds.clear(); this.tail = []; this.streamingMessageId = null; // the tail (and its slot) is gone @@ -763,10 +899,19 @@ export class SessionModel { if (baseAbsorbed) return; // not a pure append — the idle full sync is the net this.mutate(() => { this.base = record; + this.forgetResolvedDrafts(record); this.tail = this.tail.filter((m) => !absorbed(m, record)); }); } + /** Automatic mount/sync continuation is never permission to retry a + * failed turn. Read current model truth, not a stale React closure. */ + async continueIfOwed(record: readonly LhMessage[]): Promise { + if (this.statusValue === "ready" && !this.automaticContinuationBlocked && this.getState().error === null && needsContinuation(record)) { + await this.send({ type: "continue" }); + } + } + /** The mount/sync continuation fold (native-messages.ts). */ static needsContinuation = needsContinuation; diff --git a/components/session.ts b/components/session.ts index e0652cf..7c6d8dc 100644 --- a/components/session.ts +++ b/components/session.ts @@ -22,16 +22,18 @@ export interface UseChatSessionOptions { chatId: string; /** The native record seed (server-rendered page or a full sync). */ initialMessages: LhMessage[]; + /** Transient settlement failure retained by the server, not the transcript. */ + initialError?: string; /** Reattach on mount: a turn is running server-side with no live view. */ initiallyInFlight?: boolean; } -export function useChatSession({ chatId, initialMessages, initiallyInFlight }: UseChatSessionOptions) { +export function useChatSession({ chatId, initialMessages, initialError, initiallyInFlight }: UseChatSessionOptions) { // The model is per-mount (chatId-keyed component); onChange forces the // re-render. The ref pattern keeps it stable across renders. const modelRef = useRef(null); if (modelRef.current === null) { - modelRef.current = new SessionModel({ chatId, initialMessages }); + modelRef.current = new SessionModel({ chatId, initialMessages, initialError }); } const model = modelRef.current; // The render VERSION — bumped by every model change, so the state memo @@ -101,6 +103,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U ); const send = useCallback((input: ChatInput) => void model.send(input), [model]); + const continueIfOwed = useCallback((record: readonly LhMessage[]) => void model.continueIfOwed(record), [model]); const steer = useCallback((text: string) => void model.steer(text), [model]); const submitAnswer = useCallback( (tool: string, toolCallId: string, output: QuestionAnswerData) => @@ -115,7 +118,9 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U (problemCallId: string) => void model.dismissProblem(problemCallId), [model], ); - const setMessages = useCallback((record: LhMessage[]) => model.setMessages(record), [model]); + const questionDraft = useCallback((toolCallId: string) => model.questionDraft(toolCallId), [model]); + const saveQuestionDraft = useCallback((toolCallId: string, draft: Parameters[1]) => model.saveQuestionDraft(toolCallId, draft), [model]); + const setMessages = useCallback((record: LhMessage[], turnError?: string) => model.setMessages(record, turnError), [model]); const adoptRecord = useCallback((record: LhMessage[]) => model.adoptRecord(record), [model]); const stop = useCallback(() => model.stop(), [model]); const clearError = useCallback(() => model.clearError(), [model]); @@ -129,10 +134,13 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U error: state.error, steerQueue: state.steerQueue, send, + continueIfOwed, steer, submitAnswer, dismissQuestion, dismissProblem, + questionDraft, + saveQuestionDraft, setMessages, adoptRecord, stop, diff --git a/docs/architecture.md b/docs/architecture.md index 1fcce49..ed7d869 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -307,7 +307,9 @@ sequenceDiagram - **The prober→teacher handoff**: the first turn(s) run the prober (floor/ceiling via quiz, goal via ask); `probe_done` (server-executed) files the assessment and queues a handoff steer; from the first `tool-probe_done` part in the transcript, the handler builds the teacher agent for every later turn. Phases (`core/phases.ts`) fold strictly: probe → plan → teach. - **The formal plan tree is author-once**: the teacher authors the session's dependency map with a single schema-validated `plan_init` tool (a mermaid-subset graph, parsed deterministically with line-numbered errors, validated as a DAG). Progress is never mutated — statuses (`current`/`visited`/upcoming) are **derived** from tagged quiz calls in the transcript; the tree panel and the plan blocks render that derivation. - **Job tools are server-executed, ack-and-steer**: `research`/`demo`/`visual` resolve immediately mid-turn with a fire-and-forget ack (the teacher keeps teaching); `spawn(job, input)` fires the background runner. -- Each turn service is a named, error-contained seam taking explicit deps; failures log one `[chat ]` line and never touch the response. Swapping any of them touches nothing else. +- Turn services are named seams taking explicit dependencies. Extraction contains its errors, but **canonical persistence must not fail silently**: a failed input write refuses acceptance without starting an agent; `persistTurn` throws on a failed reply write. The runner reports settlement failure before `[DONE]` and releases liveness even when normal settlement fails. Steer reconciliation uses the durable record, so a later bookkeeping failure cannot replay already-saved learner input. +- **Late attachment must still see settlement failure.** A small, memory-only notice per session precedes the settled stream terminal when the failing turn has already left the live registry. The next accepted turn, successful session deletion, or server exit clears it. Reopening seeds the existing banner from that notice and waits for ordinary learner input; dismissing the banner is not permission to automatically retry the model. No synthetic failure entry is added to the saved transcript. +- **Recovery never blindly resends an answer.** Submitted question drafts survive optimistic unmount in memory; the existing Submit action reconciles uncertain acceptance against the canonical record and running-turn state before sending again. EOF or a rejected stream read without a terminal triggers one read-only reattachment; another transport failure shows the existing error banner rather than claiming completion. Explicit completion, cancellation, and the literal-final-entry saved-error rule remain unchanged. ### 5.1 The wake pipeline (Workbench → Classroom) diff --git a/src/core/question-core.ts b/src/core/question-core.ts index f0b3214..8ba6c87 100644 --- a/src/core/question-core.ts +++ b/src/core/question-core.ts @@ -511,6 +511,21 @@ export interface DockSelection { export const emptyDockSelection = (): DockSelection => ({ selected: new Set(), otherSelected: false, dontKnow: false }); +/** In-memory submission snapshot: survives the question form's optimistic + * unmount, never written to storage. Indices use the call's stable shuffle. */ +export interface QuestionFormDraft { + selection: DockSelection; + thinking: string; + other: string; + text: string; + confidenceIndex: number; +} + +export interface QuestionDraft { + form?: QuestionFormDraft; + readingThinking?: string; +} + /** * Toggling a listed option. Single-select: the option becomes THE answer and * displaces "Other". Multi-select: the option toggles alongside its siblings diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index af92de7..56132e9 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -42,8 +42,8 @@ import { import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope, type ProviderKeyMissing } from "../core/settings.ts"; import { beginTurn, endTurn } from "./turn-liveness.ts"; import { applyParkedDismissal } from "./dismissal-park.ts"; -import { registerTurn, unregisterTurn } from "./turn-runs.ts"; -import { persistTurn, triggerExtraction } from "./turn-service.ts"; +import { registerTurn, rememberTurnFailure, unregisterTurn } from "./turn-runs.ts"; +import { persistTurn, triggerExtraction, TurnPersistenceError } from "./turn-service.ts"; import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts"; import { composeInstructions } from "../agent/teacher-agent.ts"; import { ensureCompacted, kickCompaction, effectiveThresholdTokens } from "./compaction.ts"; @@ -79,6 +79,10 @@ import type {CallUsage, SteerMessage} from "../core/contracts.ts"; * for write-ahead persistence, turn liveness, and the turn services. */ +function settlementErrorText(error: unknown): string { + return error instanceof TurnPersistenceError ? error.message : "An error occurred."; +} + const jobRunner = createJobRunner({ chatStore: stores.chatStore, jobStore: stores.jobStore, @@ -289,7 +293,11 @@ export async function handleChatInput( // the composer gates), so adjacency holds by construction. if (input.type === "answer" && danglingQuestionOf([...record.messages, ...appends]) !== null) { const merged = [...record.messages, ...appends]; - stores.chatStore.saveMessages(chatId, merged); + try { stores.chatStore.saveMessages(chatId, merged); } + catch (err) { + console.error(`[chat ${chatId}] write-ahead persist failed:`, err); + return new Response("Your input could not be saved. Please try again.", { status: 500 }); + } emit("record-changed", { chatId }); return settledTurnResponse(); // the cheap terminal — no beginTurn, no turn } @@ -313,14 +321,19 @@ export async function handleChatInput( let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length; try { if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged); - // Dock fix (owner ruling 2026-09-26): persisted markers are visible - // in the record — flip their jobs now (the wake's steers ride this - // write-ahead; the delivering turn may run for minutes). + } catch (err) { + console.error(`[chat ${chatId}] write-ahead persist failed:`, err); + endTurn(chatId); + return new Response("Your input could not be saved. Please try again.", { status: 500 }); + } + // Visibility bookkeeping is not the canonical save. Its failure must + // neither misreport a saved input as lost nor prevent the turn running. + try { for (const appended of stagedAutomatic ? [] : appends) { if ((appended as { role?: string }).role === "custom") noteSteerVisible(appended as SteerMessage); } } catch (err) { - console.error(`[chat ${chatId}] write-ahead persist failed:`, err); + console.error(`[chat ${chatId}] input visibility bookkeeping failed:`, err); } // MONEY CHANNEL (wave 4): one collector per request, fed by the agent's @@ -496,6 +509,21 @@ export async function handleChatInput( console.error(`[chat ${chatId}] turn error:`, error); return "An error occurred."; }, + onSettleError: settlementErrorText, + onSettleFailure: (error, reportedErrorText) => { + // POST only acknowledges acceptance; the submitter may attach + // after this turn has already failed and left the registry. + rememberTurnFailure(chatId, reportedErrorText ?? settlementErrorText(error)); + endTurn(chatId); + unregisterTurn(chatId); + unregisterLiveAgent(chatId); + // The canonical save may have succeeded before later bookkeeping + // threw. Reconcile against actual durability, never replay input + // merely because usage/extraction/other settlement work failed. + const persisted = stores.chatStore.getChat(chatId)?.messages ?? []; + reconcileUserSteers(chatId, persisted); + settleSteers(chatId, persisted); + }, onFinish: (turnMessages, isAborted) => { // Wave B: the record write is the agent's own final state — // the new messages are the slice past the merged base. diff --git a/src/server/chats.ts b/src/server/chats.ts index f90d950..b1976ca 100644 --- a/src/server/chats.ts +++ b/src/server/chats.ts @@ -5,6 +5,7 @@ import { titleFromPrompt } from "../core/contracts.ts"; import { graphRevision } from "./graph.ts"; import { stores } from "./stores.ts"; import { isTurnRunning } from "./turn-liveness.ts"; +import { clearTurnFailure } from "./turn-runs.ts"; /** * Server-side chat entries for the transport layer (app/api/chats*, @@ -108,7 +109,7 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P // ledgers → jobs → the chat record. const stats = deleteChatTraces(stores.graphStore, chatId, mode); const jobsRemoved = stores.jobStore.deleteJobsForChat(chatId); - stores.chatStore.deleteChat(chatId); + if (stores.chatStore.deleteChat(chatId)) clearTurnFailure(chatId); return { ok: true, mode, stats, jobsRemoved, graphRevision: graphRevision() }; } diff --git a/src/server/pi/ui-stream.ts b/src/server/pi/ui-stream.ts index b75a570..2619ac4 100644 --- a/src/server/pi/ui-stream.ts +++ b/src/server/pi/ui-stream.ts @@ -79,6 +79,10 @@ export interface PiTurnSpec { * event stream itself is verbatim — provider errors ride their * message's stopReason/errorMessage through untouched). */ onError?: (error: unknown) => string; + /** Report settlement failure while the views are still open. */ + onSettleError?: (error: unknown) => string; + /** Release liveness even if the normal finish seam throws. */ + onSettleFailure?: (error: unknown, reportedErrorText?: string) => void; /** The canonical end-of-turn seam: persist + usage + extraction + endTurn. * Receives the agent's FULL final state (native messages) — the caller * persists the slice past its own base count. */ @@ -119,11 +123,12 @@ export interface PiTurnHandle { } export function runPiTurn(spec: PiTurnSpec): PiTurnHandle { - const { agent, onError, onFinish, usedToolCallIds, updateFlushMs = 100, requestCycle } = spec; + const { agent, onError, onFinish, onSettleError, onSettleFailure, usedToolCallIds, updateFlushMs = 100, requestCycle } = spec; const uniquifier = usedToolCallIds ? new ToolCallIdUniquifier(usedToolCallIds) : undefined; let settled = false; let sawAbort = false; + let reportedErrorText: string | undefined; // THE PER-TURN STATE LOG (memory-only — the settle-only disk writes // are unchanged). COMPACTION LAW (owner, 2026-10-09, supersedes the @@ -221,11 +226,22 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle { // messages of its own (the prompt is composed per turn), so dropping // the role wholesale is exact; revisit if prompt-updates ever land. const settleWith = () => { - const settled_ = [...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system"); - onFinish((uniquifier?.remapSettled(settled_) as AgentMessage[]) ?? settled_, sawAbort); + try { + const settled_ = [...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system"); + onFinish((uniquifier?.remapSettled(settled_) as AgentMessage[]) ?? settled_, sawAbort); + } catch (err) { + console.error("[turn] settlement failed:", err); + try { onSettleFailure?.(err, reportedErrorText); } + catch (cleanupError) { console.error("[turn] settlement cleanup failed:", cleanupError); } + if (reportedErrorText === undefined) { + reportedErrorText = onSettleError?.(err) ?? "An error occurred."; + send({ type: "error", errorText: reportedErrorText }); + } + } }; const handle = (event: AgentEvent) => { + if (settled) return; // THE LACAN FIX: ids uniquify HERE, before any view — the one seam // every consumer downstream shares. No uniquifier → verbatim. const uniqued = uniquifier ? uniquifier.event(event) : event; @@ -246,8 +262,9 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle { if (m?.role === "custom") noteSteerVisible(event.message as SteerMessage); } if (event.type === "agent_end") { - closeAll(); + // Persist/report before the terminal closes every view to new frames. settleWith(); + closeAll(); } }; requestCycle?.bindRelease(handle); @@ -273,11 +290,15 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle { // stream as stopReason "error"): surface, close, persist. If the // agent already settled (agent_end reached before the throw), // the seam has run — never settle twice. - if (settled) return; + if (settled) { + console.error("[turn] error after settlement:", err); + return; + } console.error("[turn] agent loop failed:", err); - send({ type: "error", errorText: onError?.(err) ?? "An error occurred." }); - closeAll(); + reportedErrorText = onError?.(err) ?? "An error occurred."; + send({ type: "error", errorText: reportedErrorText }); settleWith(); + closeAll(); } })(); diff --git a/src/server/turn-liveness.ts b/src/server/turn-liveness.ts index 27c8cc1..b5821fc 100644 --- a/src/server/turn-liveness.ts +++ b/src/server/turn-liveness.ts @@ -21,6 +21,7 @@ */ import { emit } from "../core/events.ts"; +import { clearTurnFailure } from "./turn-runs.ts"; interface LhGlobal { __lhTurnRegistry?: Map; @@ -43,15 +44,17 @@ const MAX_TURN_MS = 30 * 60_000; */ export function beginTurn(chatId: string): boolean { if (isTurnRunning(chatId)) return false; + clearTurnFailure(chatId); inFlight.set(chatId, Date.now()); emit("turn-started", { chatId }); return true; } /** - * Mark the chat's turn as finished (runs in onFinish, aborted or not — - * AFTER persistTurn, so "turn-settled" guarantees a subsequent fetch reads - * durable truth). Emits only when an entry was actually present: a + * Mark the chat's turn as finished after normal settlement, or after the + * failure notice has been retained by settlement cleanup. A subsequent + * fetch reads durable truth, never a promise that an unsaved reply landed. + * Emits only when an entry was actually present: a * defensive no-op endTurn must not fire a spurious settle. */ export function endTurn(chatId: string): void { diff --git a/src/server/turn-runs.ts b/src/server/turn-runs.ts index 29bf49a..3fbf9e7 100644 --- a/src/server/turn-runs.ts +++ b/src/server/turn-runs.ts @@ -13,7 +13,9 @@ * Registration lifecycle: chat-handler registers the handle the moment * the turn starts (after the 409 guard) and its onFinish unregisters LAST * — after persist, usage, extraction, and endTurn — so anything that sees - * "no running turn" also sees an already-persisted record. The per-chat + * "no running turn" sees durable record truth. Failed settlement also + * retains its small notice before unregistering, so late attachments do + * not mistake an unsaved reply for success. The per-chat * 409 guard means a chatId can hold at most one handle, and beginTurn's * 30-minute TTL bounds the pathological never-settling case the same way * it does for the liveness map: if onFinish never fires, the stale entry @@ -30,6 +32,7 @@ import type { PiTurnHandle } from "./pi/ui-stream.ts"; interface LhTurnRunsGlobal { __lhTurnRuns?: Map; + __lhTurnFailures?: Map; } function runs(): Map { @@ -37,6 +40,25 @@ function runs(): Map { return (g.__lhTurnRuns ??= new Map()); } +/** One small, transient notice per failed session. Never stores messages. + * Cleared on the next accepted begin, session deletion, or process exit. */ +function failures(): Map { + const g = globalThis as typeof globalThis & LhTurnRunsGlobal; + return (g.__lhTurnFailures ??= new Map()); +} + +export function rememberTurnFailure(chatId: string, errorText: string): void { + failures().set(chatId, errorText); +} + +export function turnFailure(chatId: string): string | undefined { + return failures().get(chatId); +} + +export function clearTurnFailure(chatId: string): void { + failures().delete(chatId); +} + /** Register the chat's running turn (chat-handler, right after the turn * starts). One per chat by construction — the 409 guard. */ export function registerTurn(chatId: string, handle: PiTurnHandle): void { @@ -67,11 +89,14 @@ export function abortTurn(chatId: string): { ok: true } | { ok: false; reason: " /** The cheap terminal when GET /api/chats/[id]/stream finds no running * turn (raced settle, or the client checked turnInFlight a beat too * late): a same-dialect SSE response whose single chunk tells the client - * "nothing to attach to — the record is current, refetch it". Chosen as - * an EVENT (not a status code) so the reattach path consumes ONE wire + * "nothing to attach to — refetch durable truth". A retained settlement + * failure is delivered first, including when no view attached in time. + * Chosen as an EVENT (not a status code), consuming ONE wire * protocol end to end. */ -export function settledTurnResponse(): Response { - return new Response('data: {"type":"turn-settled"}\n\ndata: [DONE]\n\n', { +export function settledTurnResponse(chatId?: string): Response { + const errorText = chatId ? turnFailure(chatId) : undefined; + const notice = errorText === undefined ? "" : `data: ${JSON.stringify({ type: "error", errorText })}\n\n`; + return new Response(`${notice}data: {"type":"turn-settled"}\n\ndata: [DONE]\n\n`, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", diff --git a/src/server/turn-service.ts b/src/server/turn-service.ts index b369697..ef7728a 100644 --- a/src/server/turn-service.ts +++ b/src/server/turn-service.ts @@ -1,5 +1,5 @@ /** - * The chat-turn services — one named, error-contained seam per turn + * The chat-turn services — one named seam per turn * responsibility, extracted from the chat-handler so it composes instead of * implementing. Each takes its dependencies explicitly (storage ports, the * job runner) — no module here imports an adapter. @@ -26,6 +26,14 @@ import type { ChatStore, GraphStore } from "../core/ports.ts"; import type { ExtractionPipeline } from "../agent/subagents/extraction.ts"; import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/settings.ts"; +/** Distinguish a failed canonical save from later bookkeeping failures. */ +export class TurnPersistenceError extends Error { + constructor(cause: unknown) { + super("The reply could not be saved.", { cause }); + this.name = "TurnPersistenceError"; + } +} + /** * The canonical state write (Wave B, owner-approved 2026-09-26): append the * turn's own messages — the agent loop's native history, provider/model/ @@ -45,7 +53,8 @@ import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/setti * - Plan blocks: one frozen snapshot per successful plan_init call, * idempotent (persist replays are safe). * - * Returns the stamped new messages (usage provenance reads them). + * Returns the stamped new messages (usage provenance reads them). Throws + * TurnPersistenceError on failure so the runner can report it before DONE. */ export function persistTurn( chatStore: ChatStore, @@ -56,7 +65,7 @@ export function persistTurn( ): LhMessage[] { try { const record = chatStore.getChat(chatId); - if (!record) return []; + if (!record) throw new Error(`Chat not found: ${chatId}`); const stamped: LhMessage[] = []; for (const m of turnMessages.slice(baseCount)) { const message = m as LhMessage; @@ -79,7 +88,7 @@ export function persistTurn( return stamped; } catch (err) { console.error(`[chat ${chatId}] persist failed:`, err, { isAborted }); - return []; + throw new TurnPersistenceError(err); } } diff --git a/tests/chat-flow.test.ts b/tests/chat-flow.test.ts index ed25fcc..82ed82f 100644 --- a/tests/chat-flow.test.ts +++ b/tests/chat-flow.test.ts @@ -30,13 +30,14 @@ import { createTeacherTools } from "../src/agent/tools.ts"; import { handleChatInput, handleChatRequest } from "../src/server/chat-handler.ts"; import { effectiveSettings } from "../src/server/stores.ts"; import { isTurnRunning } from "../src/server/turn-liveness.ts"; -import { deliverJobResult, setTurnRunner } from "../src/server/jobs.ts"; +import { deliverJobResult, registerLiveAgent, setTurnRunner, steerLiveChat, unregisterLiveAgent } from "../src/server/jobs.ts"; +import { SessionModel } from "../components/session-core.ts"; import { buildSubagentSteer } from "../src/core/contracts.ts"; import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts"; import { chatStore } from "../src/store/chats.ts"; import { jobStore } from "../src/store/jobs.ts"; import { buildAnswer, dismissedAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts"; -import { danglingQuestionOf, endsInDismissal, needsContinuation, newAnswerResult } from "../src/core/native-messages.ts"; +import { danglingQuestionOf, endsInDismissal, needsContinuation, newAnswerResult, newUserMessage } from "../src/core/native-messages.ts"; import { projectView } from "../src/core/view.ts"; import type { PiEngine } from "../src/agent/models.ts"; import type { LhMessage } from "../src/core/native-messages.ts"; @@ -46,6 +47,8 @@ import type { LhMessage } from "../src/core/native-messages.ts"; // store imports already resolved under). const dismissRoute = await import("../app/api/chats/[id]/dismiss/route.ts"); const steerRoute = await import("../app/api/chats/[id]/steer/route.ts"); +const streamRoute = await import("../app/api/chats/[id]/stream/route.ts"); +const recordRoute = await import("../app/api/chats/[id]/route.ts"); import { PATHS } from "../config.ts"; // The key-presence turn gate (provider-key interlude, 2026-09-25): scripted @@ -845,6 +848,103 @@ describe("chat store", () => { }); }); +describe("canonical persistence failures", () => { + it("refuses a failed input save without building an agent or leaving a live turn", async () => { + const { engine } = scriptedTeacher(defaultLesson); + const chat = createConfiguredChat("Test: failed answer write-ahead"); + createdChats.push(chat.id); + await drainTurn(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine)); + const before = chatStore.getChat(chat.id)!.messages; + const save = chatStore.saveMessages; + let builds = 0; + chatStore.saveMessages = () => { throw new Error("synthetic write-ahead failure"); }; + try { + const res = await handleChatRequest(post(chat.id, { type: "answer", toolCallId: "call-1", answer: gradedAnswer(QUIZ_INPUT, ["Mercury"]) }), (opts) => { + builds++; + return agentFactory(engine)!(opts); + }); + const running = runningTurn(chat.id); + if (running) await new Response(running.subscribe()).text(); + assert.equal(res.status, 500); + const client = new SessionModel({ chatId: chat.id, initialMessages: before, fetch: async () => res.clone() }); + await client.submitAnswer("quiz", "call-1", gradedAnswer(QUIZ_INPUT, ["Mercury"])); + assert.equal(client.getState().error?.message, "Your input could not be saved. Please try again."); + assert.equal(await res.text(), "Your input could not be saved. Please try again."); + assert.equal(builds, 0); + assert.equal(isTurnRunning(chat.id), false); + assert.equal(runningTurn(chat.id), undefined); + assert.deepEqual(chatStore.getChat(chat.id)!.messages, before); + } finally { chatStore.saveMessages = save; } + }); + + it("reports a failed reply save before DONE while keeping the accepted answer durable", async () => { + const { engine } = scriptedTeacher(defaultLesson); + const chat = createConfiguredChat("Test: failed reply persistence"); + createdChats.push(chat.id); + await drainTurn(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine)); + const save = chatStore.saveMessages; + let writes = 0; + chatStore.saveMessages = (id, messages) => { + if (++writes === 2) throw new Error("synthetic reply save failure"); + return save(id, messages); + }; + try { + const body = await drainTurn(post(chat.id, { type: "answer", toolCallId: "call-1", answer: gradedAnswer(QUIZ_INPUT, ["Mercury"]) }), agentFactory(engine)); + const errors = eventsOf(body).filter(e => e.type === "error"); + assert.equal(errors.length, 1); + assert.equal(errors[0]!.errorText, "The reply could not be saved."); + assert.ok(body.indexOf("The reply could not be saved.") < body.indexOf("[DONE]")); + assert.equal(isTurnRunning(chat.id), false); + assert.equal(runningTurn(chat.id), undefined); + assert.equal(chatStore.getChat(chat.id)!.messages.at(-1)?.role, "toolResult"); + // The normal submitter may not reach GET /stream until AFTER the + // failing turn has left the live registry. It must still see it. + const late = await streamRoute.GET(new Request("http://test/stream"), { params: Promise.resolve({ id: chat.id }) }); + assert.equal(eventsOf(await late.text()).find(e => e.type === "error")?.errorText, "The reply could not be saved."); + // An already-open idle view can miss both the start and the wire + // error, then learn of settlement only via a record synchronization. + const snapshot = await (await recordRoute.GET(new Request("http://test/record"), { params: Promise.resolve({ id: chat.id }) })).json(); + assert.equal(needsContinuation(snapshot.messages), true, "the durable answer alone looks like an owed continuation"); + let automaticPosts = 0; + const missedView = new SessionModel({ chatId: chat.id, initialMessages: [], fetch: async () => { + automaticPosts++; + return new Response("unexpected automatic retry", { status: 500 }); + } }); + missedView.setMessages(snapshot.messages, snapshot.turnError); + await missedView.continueIfOwed(snapshot.messages); + assert.equal(automaticPosts, 0, "the record notice must block replacement even without a stream attachment"); + assert.equal(missedView.getState().error?.message, "The reply could not be saved."); + } finally { chatStore.saveMessages = save; } + }); + + it("does not redeliver a persisted learner steer after usage bookkeeping fails", async () => { + const { engine } = scriptedTeacher(() => [() => fauxAssistantMessage([fauxText("Synthetic response")])]); + const chat = createConfiguredChat("Test: post-save bookkeeping failure"); + createdChats.push(chat.id); + const steer = newUserMessage("Synthetic learner steer"); + const appendUsage = chatStore.appendTurnUsage; + chatStore.appendTurnUsage = () => { throw new Error("synthetic usage write failure"); }; + try { + const body = await drainTurn(post(chat.id, { type: "text", text: "go" }), opts => { + const agent = createTeacherAgent(undefined, { engine, messages: opts.messages, onUsage: opts.onUsage }); + agent.subscribe(event => { + if (event.type === "agent_start") assert.equal(steerLiveChat(chat.id, steer), true); + }); + return agent; + }); + assert.equal(eventsOf(body).find(e => e.type === "error")?.errorText, "An error occurred."); + assert.ok(chatStore.getChat(chat.id)!.messages.some(m => m.id === steer.id), "the steer was saved before bookkeeping failed"); + const delivered: LhMessage[] = []; + const next = { steer: (message: LhMessage) => delivered.push(message), state: { model: { provider: "faux" } } } as unknown as Parameters[1]; + registerLiveAgent(chat.id, next); + assert.deepEqual(delivered, [], "an already-saved learner steer must not reach another agent"); + } finally { + chatStore.appendTurnUsage = appendUsage; + unregisterLiveAgent(chat.id); + } + }); +}); + describe("mid-turn navigation (turn liveness + write-ahead)", () => { it("one turn per chat: a concurrent request is 409'd; the next one after finish is accepted", async () => { const { engine } = scriptedTeacher(defaultLesson); diff --git a/tests/graph.test.ts b/tests/graph.test.ts index e21203c..5c454dc 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -657,6 +657,7 @@ describe("graph ledger store", () => { import { deleteChatTraces, type ChatDeletionStats } from "../src/core/graph-deletion.ts"; import { deleteChatSession, sessionCoverage } from "../src/server/chats.ts"; +import { rememberTurnFailure, settledTurnResponse } from "../src/server/turn-runs.ts"; import { beginTurn, endTurn } from "../src/server/turn-liveness.ts"; import { jobStore } from "../src/store/jobs.ts"; import { @@ -811,11 +812,13 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () = const turnedAway = await deleteChatSession(dA.id, "delete-nodes"); assert.deepEqual(turnedAway, { ok: false, status: 409, code: "turn_in_flight", error: "A turn is running in this session — try again when it finishes", jobIds: [] }); endTurn(dA.id); - // 409 — running background jobs. + // 409 — running background jobs; a refused delete keeps its notice. + rememberTurnFailure(dA.id, "The reply could not be saved."); const job = jobStore.createJob({ kind: "research", chatId: dA.id, toolCallId: "del-call-1" }); const jobsBlocked = await deleteChatSession(dA.id, "delete-nodes"); assert.ok(!jobsBlocked.ok && jobsBlocked.status === 409, "refused with 409"); if (jobsBlocked.code === "jobs_running") assert.deepEqual(jobsBlocked.jobIds, [job.id]); + assert.match(await settledTurnResponse(dA.id).text(), /The reply could not be saved\./); assert.equal(jobStore.deleteJobsForChat(dA.id), 1, "test cleanup — the job file is gone"); // Success — a terminal job rides along; the chat record dies last. @@ -830,6 +833,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () = assert.ok(outcome.graphRevision > 0, "the version stamp moved"); } assert.equal(chatStore.getChat(dA.id), null, "the chat record is gone"); + assert.doesNotMatch(await settledTurnResponse(dA.id).text(), /"type":"error"/, "deletion clears its transient notice"); assert.equal(jobStore.getJob(doneJob.id), null, "the chat's job records are gone"); // And a second call is an honest 404. assert.deepEqual(await deleteChatSession(dA.id, "delete-nodes"), { ok: false, status: 404, code: "not_found", error: "No such session" }); diff --git a/tests/render-cards.test.tsx b/tests/render-cards.test.tsx index f473f42..d5ae8d3 100644 --- a/tests/render-cards.test.tsx +++ b/tests/render-cards.test.tsx @@ -78,6 +78,31 @@ function renderDock(props: { ); } +describe("question submission draft restoration", () => { + const draft = { + selection: { selected: new Set([0]), otherSelected: true, dontKnow: false }, + thinking: "preserved reasoning", + other: "preserved custom answer", + text: "preserved free-text answer", + confidenceIndex: 4, + }; + + it("restores a quiz selection, thinking, and confidence when its form remounts", () => { + const html = renderToStaticMarkup( {}} onSubmit={() => {}} initialDraft={draft} />); + assert.match(html, /role="radio" aria-checked="true" data-kbdrow="0"/); + assert.ok(html.includes("preserved reasoning")); + assert.ok(html.includes("90%")); + }); + + it("restores an ask's custom answer and a free-text answer", () => { + const custom = renderToStaticMarkup( {}} onSubmit={() => {}} initialDraft={draft} />); + assert.ok(custom.includes('value="preserved custom answer"')); + assert.match(custom, /aria-checked="true" data-testid="other-option"/); + const text = renderToStaticMarkup( {}} onSubmit={() => {}} initialDraft={draft} />); + assert.ok(text.includes("preserved free-text answer")); + }); +}); + describe("DispatchCard — the BYOK refusal tone (owner, 2026-09-22)", () => { const job = (over: Partial): JobRecord => ({ id: "j1", diff --git a/tests/session-core.test.ts b/tests/session-core.test.ts index 7e5ecd7..27c3110 100644 --- a/tests/session-core.test.ts +++ b/tests/session-core.test.ts @@ -684,6 +684,334 @@ describe("provider-error banner (live and saved)", () => { }); }); +describe("truncated stream recovery", () => { + const truncated = () => sseResponse({ type: "message_start", message: { role: "assistant", content: [{ type: "text", text: "partial" }] } }); + const completed = () => sseResponse({ type: "message_start", message: { role: "assistant", content: [{ type: "text", text: "complete" }] } }, { type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "complete" }], stopReason: "stop" } }, "[DONE]"); + + it("reattaches once after EOF without DONE and replaces the incomplete replay", async () => { + let streams = 0; + const m = model({ fetch: dietFetch({ userText: "go", stream: () => ++streams === 1 ? truncated() : completed() }) }); + await m.send({ type: "text", text: "go" }); + assert.equal(streams, 2); + assert.equal(m.status, "ready"); + assert.equal(m.getState().error, null); + assert.equal(m.getState().nativeMessages.filter(m => m.role === "assistant").length, 1); + assert.equal(m.getState().messages.at(-1)!.parts.at(-1)!.text, "complete"); + }); + + it("surfaces an error after a second truncation without retrying the input", async () => { + let streams = 0; + let posts = 0; + const m = model({ fetch: dietFetch({ onPost: () => posts++, stream: () => { streams++; return truncated(); } }) }); + await m.send({ type: "text", text: "go" }); + assert.equal(posts, 1); + assert.equal(streams, 2); + assert.equal(m.status, "error"); + assert.equal(m.getState().error?.message, "The connection dropped before the turn completed."); + }); + + it("reports the approved connection error when the recovery record read fails", async () => { + let reads = 0; + const m = model({ fetch: async (url) => { + if (String(url) === "/api/chat") return Response.json({ ok: true }); + if (String(url).endsWith("/stream")) return truncated(); + if (++reads === 1) return Response.json({ messages: [] }); + throw new TypeError("synthetic offline"); + } }); + await m.send({ type: "text", text: "go" }); + assert.equal(m.status, "error"); + assert.equal(m.getState().error?.message, "The connection dropped before the turn completed."); + }); + + it("accepts turn-settled when the original turn finished during the disconnection", async () => { + let streams = 0; + let settled = 0; + const m = model({ onReattachSettled: () => { settled++; }, fetch: dietFetch({ stream: () => ++streams === 1 ? truncated() : sseResponse({ type: "turn-settled" }, "[DONE]") }) }); + await m.send({ type: "text", text: "go" }); + assert.equal(streams, 2); + assert.equal(settled, 1); + assert.equal(m.status, "ready"); + assert.equal(m.getState().error, null); + }); + + it("also recovers a rejected body read but does not relabel event-application bugs", async () => { + let streams = 0; + const m = model({ fetch: dietFetch({ stream: () => ++streams === 1 ? new Response(new ReadableStream({ start(controller) { controller.error(new TypeError("synthetic read failure")); } })) : completed() }) }); + await m.send({ type: "text", text: "go" }); + assert.equal(streams, 2); + assert.equal(m.status, "ready"); + streams = 0; + const broken = model({ fetch: dietFetch({ stream: () => { streams++; return completed(); } }) }); + broken.applyWireEvent = () => { throw new Error("synthetic event application bug"); }; + await broken.send({ type: "text", text: "go" }); + assert.equal(streams, 1); + assert.equal(broken.getState().error?.message, "synthetic event application bug"); + }); + + it("recovers an AbortError body rejection unless the view was actually cancelled", async () => { + let streams = 0; + const m = model({ fetch: dietFetch({ stream: () => ++streams === 1 ? new Response(new ReadableStream({ + start(controller) { controller.error(new DOMException("synthetic unexpected read abort", "AbortError")); }, + })) : completed() }) }); + await m.send({ type: "text", text: "go" }); + assert.equal(streams, 2); + assert.equal(m.status, "ready"); + assert.equal(m.getState().error, null); + }); + + it("uses the approved drop message when recovery record headers arrive but its body read fails", async () => { + let reads = 0; + const m = model({ fetch: async (url) => { + if (String(url) === "/api/chat") return Response.json({ ok: true }); + if (String(url).endsWith("/stream")) return truncated(); + if (++reads === 1) return Response.json({ messages: [] }); + return new Response(new ReadableStream({ start(controller) { controller.error(new TypeError("synthetic record-body transport failure")); } })); + } }); + await m.send({ type: "text", text: "go" }); + assert.equal(m.status, "error"); + assert.equal(m.getState().error?.message, "The connection dropped before the turn completed."); + }); + + it("preserves malformed-event diagnostics rather than calling them network drops", async () => { + let streams = 0; + const m = model({ fetch: dietFetch({ stream: () => { streams++; return new Response("data: {broken}\n\n"); } }) }); + await m.send({ type: "text", text: "go" }); + assert.equal(streams, 1); + assert.equal(m.status, "error"); + assert.notEqual(m.getState().error?.message, "The connection dropped before the turn completed."); + assert.match(m.getState().error!.message, /JSON|property name/); + }); + + it("reports a failed recovery stream request without attempting a third attachment", async () => { + let streams = 0; + const m = model({ fetch: dietFetch({ stream: () => ++streams === 1 ? truncated() : new Response("synthetic unavailable", { status: 503 }) }) }); + await m.send({ type: "text", text: "go" }); + assert.equal(streams, 2); + assert.equal(m.status, "error"); + assert.equal(m.getState().error?.message, "The connection dropped before the turn completed."); + }); + + it("a superseded consume cannot recover or overwrite its replacement's state", async () => { + let streams = 0; + let closeFirst!: () => void; + let beganReading!: () => void; + const reading = new Promise(resolve => { beganReading = resolve; }); + const m = model({ fetch: dietFetch({ stream: () => ++streams === 1 ? new Response(new ReadableStream({ + start(controller) { closeFirst = () => controller.close(); }, + pull() { beganReading(); }, + }, { highWaterMark: 0 })) : completed() }) }); + const first = m.resumeStream(false); + await reading; + await m.resumeStream(false); + closeFirst(); + await first; + assert.equal(streams, 2); + assert.equal(m.status, "ready"); + assert.equal(m.getState().error, null); + assert.equal(m.getState().messages.at(-1)!.parts.at(-1)!.text, "complete"); + }); +}); + +describe("automatic continuation after recovery failure", () => { + const question = { ...assistantMsg("question", ""), content: [{ type: "toolCall", id: "q1", name: "quiz", arguments: { question: "Q?" } }] } as LhMessage; + const answer = buildAnswer({ id: "q1", kind: "quiz", question: "Q?", mode: "single-select", options: [], createdAt: "fixture" }, { dontKnow: true }); + const answered = [question, newAnswerResult("q1", "quiz", answer)]; + + it("a retained failure seeds the banner and blocks continuation even after banner dismissal", async () => { + let posts = 0; + const m = model({ initialMessages: answered, initialError: "The reply could not be saved.", fetch: dietFetch({ onPost: () => posts++, stream: () => sseResponse("[DONE]") }) }); + assert.equal(SessionModel.needsContinuation(answered), true); + assert.equal(m.getState().error?.message, "The reply could not be saved."); + await m.continueIfOwed(answered); + assert.equal(posts, 0); + m.clearError(); + await m.continueIfOwed(answered); + assert.equal(posts, 0, "closing the banner is not permission to retry the model"); + await m.send({ type: "text", text: "explicit learner input" }); + assert.equal(posts, 1); + assert.equal(m.getState().error, null); + }); + + it("a new server-owned turn clears the retained banner without releasing Thinking", () => { + const m = model({ initialMessages: answered, initialError: "The reply could not be saved." }); + assert.equal(m.getState().error?.message, "The reply could not be saved."); + assert.equal(m.applyWireEvent({ type: "agent_start" }), false); + assert.equal(m.getState().error, null, "positive new-turn evidence replaces the old transient notice"); + }); + + it("a live failed save cannot auto-continue the accepted answer during canonical sync", async () => { + let posts = 0; + const m = model({ initialMessages: answered, fetch: dietFetch({ onPost: () => posts++, stream: () => sseResponse("[DONE]") }) }); + m.applyWireEvent({ type: "error", errorText: "The reply could not be saved." }); + m.setMessages(answered); + await m.continueIfOwed(answered); + assert.equal(posts, 0); + }); + + it("a healthy owed continuation still starts once through the normal send gate", async () => { + let posts = 0; + const m = model({ initialMessages: answered, fetch: dietFetch({ onPost: () => posts++, stream: () => sseResponse("[DONE]") }) }); + await m.continueIfOwed(answered); + assert.equal(posts, 1); + }); +}); + +// ─── failed answer submissions (no replay without canonical reconciliation) ── + +describe("answer submission recovery", () => { + const question = { + ...assistantMsg("question", "Q?"), + content: [{ type: "toolCall", id: "q1", name: "quiz", arguments: { question: "Q?" } }], + } as LhMessage; + const answer = buildAnswer({ id: "q1", kind: "quiz", question: "Q?", mode: "single-select", options: [], createdAt: "fixture" }, { dontKnow: true, thinking: "keep my reasoning" }); + + it("restores the question after a failed POST and checks the record before another POST", async () => { + const calls: string[] = []; + let posts = 0; + const m = model({ initialMessages: [question], fetch: async (url) => { + calls.push(String(url)); + if (String(url) === "/api/chat") { + posts++; + return posts === 1 ? new Response("request refused", { status: 500 }) : Response.json({ ok: true }); + } + if (String(url).endsWith("/stream")) return sseResponse("[DONE]"); + return Response.json({ messages: [question], turnInFlight: false }); + } }); + await m.submitAnswer("quiz", "q1", answer); + assert.equal(m.getState().messages[0]!.parts.find(p => p.toolCallId === "q1")!.state, "input-available"); + assert.equal(m.status, "error"); + m.clearError(); // dismissing the banner must not disable recovery + await m.submitAnswer("quiz", "q1", answer); + assert.equal(posts, 2, "error must not permanently close the send gate"); + assert.deepEqual(calls.slice(0, 3), ["/api/chat", "/api/chats/chat_x", "/api/chat"]); + }); + + it("retains all submitted draft channels until the canonical answer resolves the call", async () => { + const form = { selection: { selected: new Set([1]), otherSelected: true, dontKnow: false }, thinking: "reasoning", other: "custom answer", text: "free text", confidenceIndex: 4 }; + const m = model({ initialMessages: [question], fetch: async () => { throw new TypeError("offline"); } }); + m.saveQuestionDraft("q1", { form }); + m.saveQuestionDraft("q1", { readingThinking: "reading reasoning" }); + await m.submitAnswer("quiz", "q1", answer); + assert.deepEqual(m.questionDraft("q1"), { form, readingThinking: "reading reasoning" }); + m.setMessages([question]); + assert.deepEqual(m.questionDraft("q1"), { form, readingThinking: "reading reasoning" }); + assert.equal(m.questionDraft("q2"), undefined, "a different question never inherits the draft"); + m.adoptRecord([question, newAnswerResult("q1", "quiz", answer)]); + assert.equal(m.questionDraft("q1"), undefined, "only confirmed native resolution clears it"); + }); + + it("a final 409 restores the question instead of leaving an optimistic answer", async () => { + const m = model({ initialMessages: [question], waitMaxMs: 0, fetch: async () => new Response("busy", { status: 409 }) }); + await m.submitAnswer("quiz", "q1", answer); + assert.equal(m.getState().nativeMessages.length, 1); + assert.equal(m.status, "error"); + }); + + it("does not repost an answer whose acceptance acknowledgement was lost", async () => { + let posts = 0; + const saved = JSON.parse(JSON.stringify([question, newAnswerResult("q1", "quiz", answer)])) as LhMessage[]; + const m = model({ initialMessages: [question], fetch: async (url) => { + if (String(url) === "/api/chat") { posts++; throw new TypeError("failed fetch"); } + return Response.json({ messages: saved, turnInFlight: false }); + } }); + await m.submitAnswer("quiz", "q1", answer); + await m.submitAnswer("quiz", "q1", answer); + assert.equal(posts, 1); + assert.equal(m.status, "ready"); + assert.deepEqual(m.getState().nativeMessages, saved); + }); + + it("reattaches to an accepted running turn instead of posting another answer", async () => { + const calls: string[] = []; + const m = model({ initialMessages: [question], fetch: async (url) => { + calls.push(String(url)); + if (String(url) === "/api/chat") throw new TypeError("failed fetch"); + if (String(url).endsWith("/stream")) return sseResponse("[DONE]"); + return Response.json({ messages: [question, newAnswerResult("q1", "quiz", answer)], turnInFlight: true }); + } }); + await m.submitAnswer("quiz", "q1", answer); + await m.submitAnswer("quiz", "q1", answer); + assert.deepEqual(calls, ["/api/chat", "/api/chats/chat_x", "/api/chats/chat_x/stream"]); + assert.equal(m.status, "ready"); + }); + + it("keeps the question and makes no further POST while reconciliation is unreachable", async () => { + const calls: string[] = []; + const m = model({ initialMessages: [question], fetch: async (url) => { calls.push(String(url)); throw new TypeError("offline"); } }); + await m.submitAnswer("quiz", "q1", answer); + await m.submitAnswer("quiz", "q1", answer); + assert.deepEqual(calls, ["/api/chat", "/api/chats/chat_x"]); + assert.equal(m.status, "error"); + assert.equal(m.getState().nativeMessages.some(m => m.role === "toolResult"), false); + }); + + it("does not undo an acknowledged dismissal when an unrelated send's record read fails", async () => { + const m = model({ initialMessages: [question], fetch: async (url) => { + if (String(url).endsWith("/dismiss") || String(url) === "/api/chat") return Response.json({ ok: true }); + throw new TypeError("record read failed"); + } }); + await m.dismissQuestion("q1", dismissedAnswer("quiz", "Q?")); + await m.send({ type: "text", text: "move on" }); + assert.equal(m.status, "error"); + const part = m.getState().messages.flatMap(m => m.parts).find(p => p.toolCallId === "q1")!; + assert.equal(part.state, "output-available"); + assert.equal((part.output as { dismissed: boolean }).dismissed, true); + }); + + it("restores an unconfirmed optimistic answer when the post-ack record read fails", async () => { + let posts = 0; + let reads = 0; + const saved = JSON.parse(JSON.stringify([question, newAnswerResult("q1", "quiz", answer)])) as LhMessage[]; + const m = model({ initialMessages: [question], fetch: async (url) => { + if (String(url) === "/api/chat") { posts++; return Response.json({ ok: true }); } + if (++reads === 1) throw new TypeError("record read failed"); + return Response.json({ messages: saved, turnInFlight: false }); + } }); + await m.submitAnswer("quiz", "q1", answer); + assert.equal(m.status, "error"); + assert.equal(m.getState().nativeMessages.some(m => m.role === "toolResult"), false); + await m.submitAnswer("quiz", "q1", answer); + assert.equal(posts, 1, "the acknowledged answer must not be reposted"); + assert.equal(m.status, "ready"); + assert.deepEqual(m.getState().nativeMessages, saved); + }); + + for (const next of [{ type: "text", text: "a distinct new message" }, { type: "thinking", problemCallId: "problem1", text: "new practice thinking" }] as const) { + it(`preserves the existing POST/409-wait path for a new ${next.type} submission after an error`, async () => { + const posted: ChatInput[] = []; + const m = model({ pollMs: 0, fetch: async (url, init) => { + if (String(url) === "/api/chat") { + posted.push(JSON.parse(String(init?.body)).input); + if (posted.length === 1) throw new TypeError("failed fetch"); + if (posted.length === 2) return new Response("busy", { status: 409 }); + return Response.json({ ok: true }); + } + if (String(url).startsWith("/api/jobs")) return Response.json({ turnInFlight: false }); + if (String(url).endsWith("/stream")) return sseResponse("[DONE]"); + return Response.json({ messages: [], turnInFlight: true }); + } }); + await m.send({ type: "text", text: "original input" }); + await m.send(next); + assert.deepEqual(posted, [{ type: "text", text: "original input" }, next, next]); + assert.equal(m.status, "ready"); + }); + } + + it("adopts a changed pending question instead of sending the stale answer", async () => { + let posts = 0; + const newer = { ...question, id: "new-question", content: [{ type: "toolCall", id: "q2", name: "quiz", arguments: {} }] } as LhMessage; + const m = model({ initialMessages: [question], fetch: async (url) => { + if (String(url) === "/api/chat") { posts++; throw new TypeError("failed fetch"); } + return Response.json({ messages: [question, newer], turnInFlight: false }); + } }); + await m.submitAnswer("quiz", "q1", answer); + await m.submitAnswer("quiz", "q1", answer); + assert.equal(posts, 1); + assert.deepEqual(m.getState().nativeMessages, [question, newer]); + assert.equal(m.status, "ready"); + }); +}); + // ─── reattach (resumeStream) ──────────────────────────────────────────────── describe("resumeStream (the reattach path)", () => { diff --git a/tests/turn-liveness.test.ts b/tests/turn-liveness.test.ts index 91f1a13..e9bbc03 100644 --- a/tests/turn-liveness.test.ts +++ b/tests/turn-liveness.test.ts @@ -11,8 +11,19 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { beginTurn, endTurn, isTurnRunning } from "../src/server/turn-liveness.ts"; import { subscribe, type ServerEvent } from "../src/core/events.ts"; +import { rememberTurnFailure, settledTurnResponse } from "../src/server/turn-runs.ts"; describe("turn liveness registry", () => { + it("retains a late-attach failure notice until the next accepted begin", async () => { + const id = "chat_failure_notice_unit"; + rememberTurnFailure(id, "The reply could not be saved."); + assert.match(await settledTurnResponse(id).text(), /The reply could not be saved\./); + assert.match(await settledTurnResponse(id).text(), /The reply could not be saved\./, "reading does not consume the notice"); + assert.equal(beginTurn(id), true); + assert.doesNotMatch(await settledTurnResponse(id).text(), /"type":"error"/); + endTurn(id); + }); + it("begin/end round trip; a second begin is rejected until the turn ends", () => { const id = "chat_liveness_unit"; assert.equal(isTurnRunning(id), false, "initially idle"); diff --git a/tests/turn-recovery.test.ts b/tests/turn-recovery.test.ts new file mode 100644 index 0000000..2ef7157 --- /dev/null +++ b/tests/turn-recovery.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Agent, AgentMessage } from "@earendil-works/pi-agent-core"; +import type { ChatStore } from "../src/core/ports.ts"; +import { runPiTurn } from "../src/server/pi/ui-stream.ts"; +import { persistTurn } from "../src/server/turn-service.ts"; + +/** No engine or network: exercise the runner's awaited listener seam. */ +function agentEndingWith(loopFailure?: Error): Agent { + const listeners: Array<(event: never) => unknown> = []; + return { + state: { messages: [] }, + subscribe(listener: (event: never) => unknown) { listeners.push(listener); return () => {}; }, + async continue() { + await Promise.resolve(); // let the view subscribe before the terminal + if (loopFailure) throw loopFailure; + for (const listener of listeners) await listener({ type: "agent_end", messages: [] } as never); + }, + abort() {}, + } as unknown as Agent; +} + +function wireFrames(wire: string): string[] { + return wire.split("\n\n").filter(Boolean).map(frame => frame.slice("data: ".length)); +} + +describe("turn settlement recovery", () => { + it("reports a failed canonical save before DONE and cleans up before reporting", async () => { + const order: string[] = []; + const handle = runPiTurn({ + agent: agentEndingWith(), + onFinish() { order.push("save"); throw new Error("synthetic disk failure"); }, + onSettleFailure() { order.push("cleanup"); }, + onSettleError() { order.push("report"); return "The reply could not be saved."; }, + }); + const frames = wireFrames(await new Response(handle.subscribe()).text()); + assert.deepEqual(order, ["save", "cleanup", "report"]); + assert.equal(frames.at(-1), "[DONE]"); + assert.equal(frames.filter(f => f.includes('"type":"error"')).length, 1); + assert.ok(frames.at(-2)?.includes("The reply could not be saved.")); + }); + + it("still reports settlement failure when cleanup itself throws", async () => { + const handle = runPiTurn({ + agent: agentEndingWith(), + onFinish() { throw new Error("synthetic save failure"); }, + onSettleFailure() { throw new Error("synthetic cleanup failure"); }, + onSettleError: () => "The reply could not be saved.", + }); + const frames = wireFrames(await new Response(handle.subscribe()).text()); + assert.equal(frames.at(-1), "[DONE]"); + assert.equal(frames.filter(f => f.includes('"type":"error"')).length, 1); + }); + + it("does not replace an already reported loop failure with a second settlement error", async () => { + let cleanups = 0; + const handle = runPiTurn({ + agent: agentEndingWith(new Error("synthetic loop failure")), + onError: () => "An error occurred.", + onFinish() { throw new Error("synthetic save failure"); }, + onSettleFailure(error, reportedErrorText) { + cleanups++; + assert.match(String(error), /synthetic save failure/); + assert.equal(reportedErrorText, "An error occurred."); + }, + }); + const frames = wireFrames(await new Response(handle.subscribe()).text()); + assert.equal(cleanups, 1); + assert.equal(frames.filter(f => f.includes('"type":"error"')).length, 1); + assert.equal(frames.at(-1), "[DONE]"); + }); + + it("a healthy terminal follows persistence and emits no error", async () => { + let saves = 0; + const handle = runPiTurn({ agent: agentEndingWith(), onFinish() { saves++; } }); + const frames = wireFrames(await new Response(handle.subscribe()).text()); + assert.equal(saves, 1); + assert.equal(frames.at(-1), "[DONE]"); + assert.equal(frames.some(f => f.includes('"type":"error"')), false); + }); + + it("does not contain a canonical write failure as an empty successful persist", () => { + const store = { + getChat: () => ({ messages: [] }), + saveMessages() { throw new Error("synthetic disk failure"); }, + } as unknown as ChatStore; + const message = { role: "assistant", content: [{ type: "text", text: "Synthetic reply" }], stopReason: "stop", timestamp: 1 } as AgentMessage; + assert.throws(() => persistTurn(store, "fixture", 0, [message], false), /The reply could not be saved\./); + }); +}); From 6701503801b761b2e6f5f92821d5b9fa9afdaa5d Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sun, 25 Oct 2026 23:29:00 +0200 Subject: [PATCH 18/87] fix: isolate generated SVG figures as images --- components/artifacts/svg-image.tsx | 37 +++++ components/artifacts/visual-card.tsx | 30 ++-- components/session-overlay.tsx | 35 ++--- tests/demos.test.tsx | 23 +-- tests/svg-image.test.tsx | 207 +++++++++++++++++++++++++++ 5 files changed, 282 insertions(+), 50 deletions(-) create mode 100644 components/artifacts/svg-image.tsx create mode 100644 tests/svg-image.test.tsx diff --git a/components/artifacts/svg-image.tsx b/components/artifacts/svg-image.tsx new file mode 100644 index 0000000..6c74cdd --- /dev/null +++ b/components/artifacts/svg-image.tsx @@ -0,0 +1,37 @@ +/** Two display-only repairs for inputs the old HTML embedding accepted. + * Do not rewrite stored artifacts/downloads or attempt general XML repair. + * Comments, CDATA and processing instructions are copied verbatim. */ +export function svgImageSource(svg: string): string { + let foundRoot = false; + return svg.replace( + /|$)||$)|<\?[\s\S]*?(?:\?>|$)|<\/?[a-zA-Z](?:[^<>"']|"[^"]*"|'[^']*')*>| /g, + (token) => { + if (token.startsWith("]]>'); + assert.equal(svgImageSource(source), source); + }); + + it("adds only a missing root namespace, ignoring comments and attribute values", () => { + const source = '\' xmlns:xlink="http://www.w3.org/1999/xlink">'; + const expected = source.replace('Example`; + assert.equal(svgImageSource(source), source); + } + }); + + it("converts nbsp references in text and attributes, but not comments, CDATA or processing instructions", () => { + const source = 'A B &nbsp;  &nbsp;'; + const expected = source.replace('aria-label="A B"', 'aria-label="A B"').replace('A B', 'A B'); + assert.equal(svgImageSource(source), expected); + assert.equal(svgImageSource(expected), expected); + }); +}); + +describe("generated SVG image boundary", () => { + for (const mode of ["inline", "fullscreen"] as const) { + for (const [name, source] of [["reported style rule and fragment IDs", FIGURE], ["active markup", ACTIVE_MARKUP]] as const) { + it(`${mode}: isolates ${name} inside the image URL`, () => { + const html = renderToStaticMarkup(mode === "inline" + ? + : {}} />); + const { document } = parseHTML(html); + const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!; + assertImageBoundary(holder, source); + assert.ok(holder.classList.contains("bg-white"), "white backing retained"); + }); + } + + for (const [name, original, display] of [ + ["missing namespace", 'Example', 'Example'], + ["HTML entity", 'A B', 'A B'], + ] as const) { + it(`${mode}: repairs ${name} only in the image source`, () => { + const output = { svg: original }; + const html = renderToStaticMarkup(mode === "inline" + ? + : {}} />); + const { document } = parseHTML(html); + const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!; + assertImageBoundary(holder, display); + assert.equal(output.svg, original, "artifact source was not mutated"); + }); + } + + it(`${mode}: retains inversion on the white backing`, () => { + const html = renderToStaticMarkup(mode === "inline" + ? + : {}} />); + const { document } = parseHTML(html); + const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!; + assert.match(holder.getAttribute("style")!, /invert\(1\) hue-rotate\(180deg\)/); + assert.ok(holder.classList.contains("bg-white")); + assertImageBoundary(holder, FIGURE); + }); + } + + it("inline: retains natural width and only shrinks oversized figures", () => { + const { document } = parseHTML(renderToStaticMarkup()); + const image = assertImageBoundary(document.querySelector('[data-testid="visual-svg"]')!, FIGURE); + assert.equal(image.getAttribute("width"), "320"); + assert.equal(image.className, "block h-auto max-w-full"); + assert.ok(document.querySelector('[data-testid="visual-card"]')!.classList.contains("w-fit")); + }); + + for (const width of ["", ' width="100%"']) { + it(`inline: keeps the full-width fallback for ${width || "viewBox-only"} SVG`, () => { + const source = `Example`; + const { document } = parseHTML(renderToStaticMarkup()); + const image = assertImageBoundary(document.querySelector('[data-testid="visual-svg"]')!, source); + assert.equal(image.getAttribute("width"), null); + assert.equal(image.className, "block h-auto w-full"); + assert.ok(document.querySelector('[data-testid="visual-card"]')!.classList.contains("w-full")); + }); + } + + it("fullscreen: keeps the natural-size box and zoom controls", () => { + const { document } = parseHTML(renderToStaticMarkup( {}} />)); + const holder = document.querySelector("[data-lh-overlay-chart]")!; + assert.equal(assertImageBoundary(holder, FIGURE).className, "block h-auto w-full"); + assert.match(holder.parentElement!.getAttribute("style")!, /width:320px/); + for (const label of ["Zoom in", "Zoom out", "Fit diagram to view", "Close"]) { + assert.ok(document.querySelector(`button[aria-label="${label}"]`)); + } + }); +}); + +it("the fullscreen button opens the same SVG image and inversion; download keeps original bytes", async () => { + const originalSvg = FIGURE.replace(' xmlns="http://www.w3.org/2000/svg"', '').replace("θ & φ", "θ & φ"); + const displaySvg = FIGURE.replace("θ & φ", "θ & φ"); + const dom = parseHTML(""); + const names = ["window", "document", "Element", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT"] as const; + const originals = names.map((name) => Object.getOwnPropertyDescriptor(globalThis, name)); + Object.assign(globalThis, { + window: dom.window, + document: dom.document, + Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const host = dom.document.createElement("div"); + dom.document.body.append(host); + const root = createRoot(host); + const originalCreateUrl = URL.createObjectURL; + const originalRevokeUrl = URL.revokeObjectURL; + const originalCreateElement = dom.document.createElement; + let downloaded: Blob | undefined; + let revoked: string | undefined; + let clicked: { name: string; href: string } | undefined; + URL.createObjectURL = (blob) => { + assert.ok(blob instanceof Blob); + downloaded = blob; + return "blob:figure-test"; + }; + URL.revokeObjectURL = (url) => { revoked = url; }; + function Harness() { + const [focus, setFocus] = useState<{ svg: string; inverted: boolean } | null>(null); + return ( + {}, openFigure: (svg, inverted) => setFocus({ svg, inverted }) }}> + + {focus && setFocus(null)} />} + + ); + } + try { + await act(async () => root.render()); + await act(async () => { host.querySelector('[data-testid="visual-open"]')!.dispatchEvent(new dom.window.Event("click", { bubbles: true })); }); + const inline = assertImageBoundary(host.querySelector('[data-testid="visual-svg"]')!, displaySvg); + const overlayHolder = host.querySelector("[data-lh-overlay-chart]")!; + const fullscreen = assertImageBoundary(overlayHolder, displaySvg); + assert.equal(fullscreen.getAttribute("src"), inline.getAttribute("src")); + assert.match(overlayHolder.getAttribute("style")!, /invert\(1\) hue-rotate\(180deg\)/); + + // Intercept only the download anchor; never navigate or write a real file. + dom.document.createElement = ((...args: Parameters) => { + const element = originalCreateElement.apply(dom.document, args); + if (args[0] === "a") { + element.click = () => { clicked = { name: element.getAttribute("download")!, href: element.getAttribute("href")! }; }; + } + return element; + }) as typeof originalCreateElement; + await act(async () => { host.querySelector('[data-testid="visual-download"]')!.dispatchEvent(new dom.window.Event("click", { bubbles: true })); }); + assert.ok(downloaded); + assert.equal(await downloaded.text(), originalSvg); + assert.equal(downloaded.type, "image/svg+xml"); + assert.deepEqual(clicked, { name: "figure.svg", href: "blob:figure-test" }); + assert.equal(revoked, "blob:figure-test"); + } finally { + try { + await act(async () => root.unmount()); + } finally { + URL.createObjectURL = originalCreateUrl; + URL.revokeObjectURL = originalRevokeUrl; + dom.document.createElement = originalCreateElement; + host.remove(); + for (const [i, name] of names.entries()) { + if (originals[i]) Object.defineProperty(globalThis, name, originals[i]!); + else Reflect.deleteProperty(globalThis, name); + } + } + } +}); From 46c95c7b5978c441339d4ab4d7953958ed9c7eb8 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 26 Oct 2026 00:15:59 +0200 Subject: [PATCH 19/87] fix: update Electron to 44.9.3 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7a91ffc..0c7ac4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ "@types/turndown": "^5.0.6", "@typescript-eslint/parser": "^8.68.0", "concurrently": "^10.0.5", - "electron": "^44.4.1", + "electron": "^44.9.3", "electron-builder": "26.16.1", "eslint": "^9.39.5", "eslint-import-resolver-typescript": "^4.4.5", @@ -8687,9 +8687,9 @@ } }, "node_modules/electron": { - "version": "44.4.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-44.4.1.tgz", - "integrity": "sha512-gXJarQ2Utfn8G6bp/IqLXvckOSCtrxPWjFqQaqm02Gf4zYn1aP+RN9PKimoLH/DMqjKLfBrNC4C4CviPv0cXnQ==", + "version": "44.9.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.9.3.tgz", + "integrity": "sha512-Cv9/yMnZxNWsMlIe8T4v//NKHdUvbeyoTJoXE3BIcRxus2meeFaO9h/P3zB5d4cBXum51IgAYrT2QjpIDWKpJA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 1b0c649..bd2e627 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "@types/turndown": "^5.0.6", "@typescript-eslint/parser": "^8.68.0", "concurrently": "^10.0.5", - "electron": "^44.4.1", + "electron": "^44.9.3", "electron-builder": "26.16.1", "eslint": "^9.39.5", "eslint-import-resolver-typescript": "^4.4.5", From d9fa314c20baec522a518076b28e4fbe9f6c0499 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 26 Oct 2026 01:18:53 +0200 Subject: [PATCH 20/87] fix(pdf): resolve learner paths relative to home --- src/agent/subagents/pdf-reader.ts | 3 +- tests/jobs.test.ts | 55 +++++++++++++++++++- tests/pdf-reader.test.ts | 84 +++++++++++++++++++++++++++++-- 3 files changed, 137 insertions(+), 5 deletions(-) diff --git a/src/agent/subagents/pdf-reader.ts b/src/agent/subagents/pdf-reader.ts index 462a3e3..c3feae2 100644 --- a/src/agent/subagents/pdf-reader.ts +++ b/src/agent/subagents/pdf-reader.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai"; import { normalizeContext } from "@earendil-works/pi-ai"; import { Type } from "typebox"; +import { resolveSourcePath } from "../tools/fs-shared.ts"; import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts"; import { providerEventObserver, type ProviderEventObserver } from "../provider-events.ts"; import type { PdfResult, PdfToolInput } from "../../core/contracts.ts"; @@ -166,7 +167,7 @@ export async function runPdfReaderAgent( ): Promise { const headless = opts.headless ?? getHeadless(); const maxIterations = opts.maxIterations ?? MAX_ITERATIONS; - const pdfPath = input.path; + const pdfPath = resolveSourcePath(input.path); // Deterministic upfront failure: unreadable / unparseable // for renders — the brief never starts on a broken source. diff --git a/tests/jobs.test.ts b/tests/jobs.test.ts index af9387a..73e1957 100644 --- a/tests/jobs.test.ts +++ b/tests/jobs.test.ts @@ -25,8 +25,9 @@ import assert from "node:assert/strict"; import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; -import { after, afterEach, describe, it } from "node:test"; +import { after, afterEach, before, describe, it } from "node:test"; import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type AssistantMessage, type FauxResponseStep } from "@earendil-works/pi-ai"; import { accumulateJobUsage, @@ -717,6 +718,27 @@ describe("research runner (async protocol §3)", () => { describe("pdf runner (sources v0)", () => { const PDF_FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf"); const PDF_INPUT = { path: PDF_FIXTURE, question: "What is the integral of x dx?" }; + const relativePath = path.join("Course notes", "sample document.pdf"); + let home: string; + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + + before(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pdf-job-home-")); + const target = path.join(home, relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(PDF_FIXTURE, target); + process.env.HOME = home; + process.env.USERPROFILE = home; + }); + + after(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + fs.rmSync(home, { recursive: true, force: true }); + }); /** A pdf-reader that triages, extracts pages 1-2, then briefs. */ function pdfReaderModel(): PiEngine { @@ -759,6 +781,37 @@ describe("pdf runner (sources v0)", () => { assert.ok(steer.content.includes("integral rule"), "steer text carries the brief"); }); + for (const mode of ["dispatch", "recovery"] as const) { + it(`resolves a home-relative PDF from ${mode} input without rewriting history`, async () => { + const chat = chatStore.createChat(`Test: relative PDF ${mode}`); + trackChat(chat.id); + const toolCallId = `call-pdf-relative-${mode}`; + const input = { ...PDF_INPUT, path: relativePath }; + const messages = nativeToolMessages("read this pdf", "pdf", toolCallId, input); + chatStore.saveMessages(chat.id, messages); + const job = jobStore.createJob({ kind: "pdf", chatId: chat.id, toolCallId }); + trackJob(job.id); + const delivery = fakeDeliver(); + const runner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver }); + pdfModelOverride.current = pdfReaderModel(); + try { + await runner.runJob(job.id, mode === "dispatch" ? input : undefined); + } finally { + pdfModelOverride.current = undefined; + } + + const done = jobStore.getJob(job.id)!; + assert.equal(done.status, "done"); + const result = done.result as PdfResult; + assert.equal(result.path, path.join(home, relativePath)); + assert.match(result.brief, /integral rule/); + assert.deepEqual(result.pagesRead, [1, 2]); + assert.deepEqual(result.pagesConsulted, [1, 2]); + assert.equal(delivery.steers.length, 1); + assert.deepEqual(chatStore.getChat(chat.id)!.messages, messages, "tool-call arguments remain as supplied"); + }); + } + it("an unreadable source: the job lands error with the PDF failed: brief and the steer flags the gap", async () => { const chat = chatStore.createChat("Test: pdf runner failure"); trackChat(chat.id); diff --git a/tests/pdf-reader.test.ts b/tests/pdf-reader.test.ts index 2f4c142..166c412 100644 --- a/tests/pdf-reader.test.ts +++ b/tests/pdf-reader.test.ts @@ -5,8 +5,10 @@ * The pdfjs layer itself is covered by pdf-ops.test.ts against the fixture. */ import assert from "node:assert/strict"; +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 { after, before, describe, it } from "node:test"; import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai"; import type { PiEngine } from "../src/agent/models.ts"; import { runPdfReaderAgent } from "../src/agent/subagents/pdf-reader.ts"; @@ -28,10 +30,12 @@ const PNG_1PX = Buffer.from([ 0x42, 0x60, 0x82, ]); -function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessApi & { calls: string[] } { +function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessApi & { calls: string[]; paths: string[] } { const calls: string[] = []; + const paths: string[] = []; return { calls, + paths, available: () => true, unavailableReason: () => null, runDemo: async () => { @@ -40,7 +44,8 @@ function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessAp renderSvg: async () => { throw new Error("unused"); }, - renderPdfPages: async (_pdfPath, pages) => { + renderPdfPages: async (pdfPath, pages) => { + paths.push(pdfPath); calls.push(`render:${pages.join(",")}`); rendered.pages.push(...pages); return pages.map((page) => ({ page, png: PNG_1PX })); @@ -48,6 +53,79 @@ function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessAp }; } +describe("pdf-reader source paths", () => { + let home: string; + const relativePath = path.join("Course notes", "sample document.pdf"); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + + before(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pdf-home-")); + const target = path.join(home, relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(FIXTURE, target); + process.env.HOME = home; + process.env.USERPROFILE = home; + }); + + after(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + fs.rmSync(home, { recursive: true, force: true }); + }); + + for (const kind of ["absolute", "home-relative"] as const) { + it(`${kind} path reaches text tools and rendering as the same absolute path`, async () => { + const absolutePath = path.join(home, relativePath); + const inputPath = kind === "absolute" ? absolutePath : relativePath; + assert.equal(os.homedir(), home); + assert.notEqual(path.resolve(relativePath), absolutePath, "cwd is not the learner's home"); + const headless = fakeHeadless(); + const engine = fauxEngine([ + fauxAssistantMessage([fauxToolCall("pdf_info", {}, { id: "i1" })], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxToolCall("pdf_search", { query: "integral", literal: true }, { id: "s1" })], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxToolCall("pdf_extract", { pages: "2" }, { id: "e1" })], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxToolCall("pdf_render", { pages: "3" }, { id: "r1" })], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxText("Read the requested document (p. 1, p. 2, p. 3).")]), + ]); + let lastContext = ""; + const stream = engine.streamFn; + engine.streamFn = (...args) => { + lastContext = JSON.stringify(args[1]); + return stream(...args); + }; + const result = await runPdfReaderAgent( + { path: inputPath, question: "Read the document" }, + { engine, headless }, + ); + assert.equal(result.path, absolutePath); + assert.match(result.brief, /^Read the requested document/); + assert.match(lastContext, /Pages: 3/, "metadata opened the home-anchored PDF"); + assert.deepEqual(result.pagesRead, [2, 3]); + assert.deepEqual(result.pagesConsulted, [1, 2, 3], "search and extraction opened the home-anchored PDF"); + assert.deepEqual(headless.paths, [absolutePath]); + }); + } + + it("a missing home-relative source fails before any model call", async () => { + let modelCalls = 0; + const engine = fauxEngine([]); + engine.streamFn = () => { + modelCalls++; + throw new Error("the model must not be called"); + }; + const missing = path.join("Course notes", "missing.pdf"); + const result = await runPdfReaderAgent({ path: missing, question: "anything" }, { engine }); + assert.equal(result.path, path.join(home, missing)); + assert.match(result.brief, /^PDF failed:.*ENOENT/); + assert.equal(modelCalls, 0); + assert.deepEqual(result.pagesRead, []); + assert.deepEqual(result.pagesConsulted, []); + }); +}); + describe("pdf-reader loop (faux engine)", () => { it("triage → extract → final brief: pagesConsulted tracks the extract", async () => { const engine = fauxEngine([ From 472844e6914742dcaa2ed5601f337efba56560b9 Mon Sep 17 00:00:00 2001 From: ferponse Date: Mon, 26 Oct 2026 00:45:16 +0100 Subject: [PATCH 21/87] fix: keep the opening chat text out of the URL A long first message in ?prompt= exceeds Node's header limit. The chat GET returns 431 and Electron commits an empty window. Stash the text in sessionStorage and open /chat/. --- components/chat.tsx | 22 ++++++++++++++--- components/home.tsx | 6 ++++- components/new-session.tsx | 16 +++++++----- components/opening-prompt.ts | 41 +++++++++++++++++++++++++++++++ components/session-core.ts | 19 +++++++++----- components/session.ts | 2 +- tests/render-new-session.test.tsx | 38 +++++++++++++++++++++++++--- 7 files changed, 123 insertions(+), 21 deletions(-) create mode 100644 components/opening-prompt.ts diff --git a/components/chat.tsx b/components/chat.tsx index 4986f3f..a42f023 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -26,6 +26,7 @@ import { Composer } from "@/components/composer"; import { useEnterSubmits } from "@/components/enter-submits"; import { blurAfterPointerClick } from "@/components/focus-hygiene"; import { dockStateKey } from "@/components/dock-state-key"; +import { clearOpeningPrompt, readOpeningPrompt } from "@/components/opening-prompt"; import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers"; import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation"; import { useFieldCycle } from "@/components/field-cycle"; @@ -248,13 +249,25 @@ export function Chat({ // New chat with a topic: send the first message exactly once // (initialPromptOwed — the pure gate, pinned in the test suite). + // The topic arrives as the server's ?prompt= (old links) or from the + // sessionStorage stash the composer wrote before navigating here. + // The stash stays until send resolves, so a reload before the POST + // lands can retry. sentInitial blocks a second send on this mount. const sentInitial = useRef(false); useEffect(() => { - if (!sentInitial.current && initialPromptOwed(initialMessages, initialPrompt)) { + const prompt = initialPrompt || readOpeningPrompt(chatId); + if (!sentInitial.current && prompt && initialPromptOwed(initialMessages, prompt)) { sentInitial.current = true; - void send({ type: "text", text: initialPrompt! }); + void send({ type: "text", text: prompt }).then( + (landed) => { + if (landed) clearOpeningPrompt(chatId); + }, + () => { + /* leave the stash: the next mount retries */ + }, + ); } - }, [initialPrompt, initialMessages, send]); + }, [chatId, initialPrompt, initialMessages, send]); // A turn running server-side, attached to no live view of ours (navigate // away mid-turn, or a wake turn discovered mid-session): the reattach @@ -1752,7 +1765,8 @@ export function Chat({ * 2026-10-02 abort chain proper (question before turn). */ export type EscAbortTarget = "mermaid" | "plan" | "setup" | "question" | "problem" | "turn" | null; /** - * Does the ?prompt topic still owe its first send? The gate is "no real + * Does the opening topic still owe its first send? The topic is the + * server's ?prompt= or the sessionStorage stash. The gate is "no real * conversation yet" (no user/assistant message) — NOT emptiness: a * session started with map picks carries a graph-context MARKER as * message[0], and that scaffolding must never swallow the first send diff --git a/components/home.tsx b/components/home.tsx index 4b16d62..bc3a025 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -12,6 +12,7 @@ import { DictationBar, joinDictation, useDictation } from "@/components/dictatio import { useFieldCycle } from "@/components/field-cycle"; import { SidebarToggle } from "@/components/sidebar"; import { StatsBar } from "@/components/stats-bar"; +import { stashOpeningPrompt } from "@/components/opening-prompt"; import type { TurnUsage } from "@/src/core"; /** @@ -150,7 +151,10 @@ export function Home({ // No settings PUT on create (per-session models, owner // 2026-09-28): the SERVER stamps the newborn from the strong // slot — the same default this surface's model control edits. - router.push(`/chat/${id}?prompt=${encodeURIComponent(t)}`); + // The opening text stays out of the URL (opening-prompt.ts): + // a long paste as ?prompt= is a 431 and a blank window. + stashOpeningPrompt(id, t); + router.push(`/chat/${id}`); } catch { setBusy(false); } diff --git a/components/new-session.tsx b/components/new-session.tsx index c2a0930..2d3cbbd 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -12,6 +12,7 @@ import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup- import { StatsBar } from "@/components/stats-bar"; import { ChainTableOverlay } from "@/components/chain-table.tsx"; import { ComposerActions, NodePickerControl } from "@/components/composer-actions"; +import { stashOpeningPrompt } from "@/components/opening-prompt"; import type { ChatSettings, GraphSnapshot } from "@/src/core"; /** The draft's pick set (owner round 10): the SAME shape the home pick @@ -34,11 +35,13 @@ const DOCK = "mx-auto w-full max-w-[46rem] px-4"; /** * The draft's whole behavior as one pure seam: create the session (title = - * the first message), persist the lifted pre-chat settings, return the push - * target — /chat/?prompt= — whose initialPrompt path delivers the - * message as turn 1. Injected fetch/router so tests pin the contract. - * Returns null on empty text; rethrows on transport failure (the caller's - * busy flag resets). + * the first message), persist the lifted pre-chat settings, stash the + * opening text beside the navigation, and return the push target — + * /chat/. The chat page reads the stash and delivers it as turn 1. + * The text never rides the URL: a long paste exceeds Node's header limit + * and the window commits an empty document. Injected fetch so tests pin + * the contract. Returns null on empty text; rethrows on transport failure + * (the caller's busy flag resets). */ export async function startDraftSession( text: string, @@ -64,7 +67,8 @@ export async function startDraftSession( const { id } = (await res.json()) as { id: string }; // No settings PUT on create (per-session models, owner 2026-09-28): // the SERVER stamps the newborn from the strong slot. - return `/chat/${id}?prompt=${encodeURIComponent(t)}`; + stashOpeningPrompt(id, t); + return `/chat/${id}`; } /** The pure shell (no router) — the render-test seam: the draft view's diff --git a/components/opening-prompt.ts b/components/opening-prompt.ts new file mode 100644 index 0000000..fca3c66 --- /dev/null +++ b/components/opening-prompt.ts @@ -0,0 +1,41 @@ +/** + * The opening message rides beside the navigation, never in the URL. + * + * Home and the new-session draft used to push `/chat/?prompt=`. + * Node's default max HTTP header size is 16KB. A long paste (a syllabus) + * makes that GET return 431, and Electron commits an empty document: the + * window stays the native background and looks frozen. The chat record + * already exists, with an empty transcript, because POST /api/chats + * succeeded first. + * + * sessionStorage holds the trimmed text under `lh.openingPrompt.`. + * The push target stays `/chat/`. The chat page still honors a + * `?prompt=` search param for an old link; when that param is absent it + * reads this stash and sends it once. The stash survives until that send + * resolves, so a reload before the POST lands can retry. Absent storage + * (node tests, a thrown access) is a no-op — the short path still returns. + */ + +const keyFor = (id: string) => `lh.openingPrompt.${id}`; + +function store(): Storage | null { + try { + if (typeof sessionStorage === "undefined" || sessionStorage == null) return null; + return sessionStorage; + } catch { + return null; + } +} + +export function stashOpeningPrompt(id: string, text: string): void { + store()?.setItem(keyFor(id), text); +} + +export function readOpeningPrompt(id: string): string | undefined { + const value = store()?.getItem(keyFor(id)); + return value && value.length > 0 ? value : undefined; +} + +export function clearOpeningPrompt(id: string): void { + store()?.removeItem(keyFor(id)); +} diff --git a/components/session-core.ts b/components/session-core.ts index 657d0da..08ea64a 100644 --- a/components/session-core.ts +++ b/components/session-core.ts @@ -562,8 +562,8 @@ export class SessionModel { * POST + consume. Ready-gated: one turn per client at a time (the * server's 409 + identity retry covers the wake-race window). */ - async send(input: ChatInput): Promise { - if (!this.readyRef) return; + async send(input: ChatInput): Promise { + if (!this.readyRef) return false; this.consumeAbort?.abort(); const controller = new AbortController(); this.consumeAbort = controller; @@ -590,6 +590,7 @@ export class SessionModel { if (optimistic.length > 0) this.mutate(() => { this.tail = [...this.tail, ...optimistic]; }); + let accepted = false; try { // SUBMITS ARE NOT ABORTABLE BY VIEW TEARDOWN (owner, 2026-10-10 — // the dead-session-start postmortem): the POST must NOT ride @@ -607,7 +608,7 @@ export class SessionModel { if (res.status === 409) { this.setError("A turn is already running — try again in a moment."); this.setStatus("error"); - return; + return false; } if (!res.ok) { // Refusal envelopes (503 BYOK/key gates) and 400s: the RAW body @@ -615,6 +616,7 @@ export class SessionModel { // page's refusal flow JSON-parses the envelope out of it. throw new Error((await res.text()) || `The send failed (${res.status}).`); } + accepted = true; // CONNECTION DIET B (owner, 2026-10-10 — parallel-session freeze // postmortem): the POST acknowledges and closes; there is no stream // on this response to consume. Attach through the SAME reattach wire @@ -626,12 +628,17 @@ export class SessionModel { // A stop() or a newer send/consume won while the ack was in // flight: the turn is live server-side either way — do not attach // a stream from a view that is gone or superseded. - if (this.consumeAbort !== controller) return; + if (this.consumeAbort !== controller) return true; await this.resumeStream(true); + return true; } catch (err) { - if ((err as Error).name === "AbortError") return; // stop() or a newer consume won + if ((err as Error).name === "AbortError") return accepted; // stop() or a newer consume won this.setError((err as Error).message); this.setStatus("error"); + // true only once the POST ack landed. A throw before that (the + // fetch, or !res.ok) leaves the message unsent — the caller keeps + // its opening-text stash and a reload can retry. + return accepted; } } @@ -681,7 +688,7 @@ export class SessionModel { * result (fork 1), riding as its input event. */ submitAnswer(tool: string, toolCallId: string, output: QuestionAnswerData): Promise { void tool; - return this.send({ type: "answer", toolCallId, answer: output }); + return this.send({ type: "answer", toolCallId, answer: output }).then(() => undefined); } /** The dismissal path (owner design 2026-10-03, "A2"): the skipped diff --git a/components/session.ts b/components/session.ts index e0652cf..22b21af 100644 --- a/components/session.ts +++ b/components/session.ts @@ -100,7 +100,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U [model], ); - const send = useCallback((input: ChatInput) => void model.send(input), [model]); + const send = useCallback((input: ChatInput) => model.send(input), [model]); const steer = useCallback((text: string) => void model.steer(text), [model]); const submitAnswer = useCallback( (tool: string, toolCallId: string, output: QuestionAnswerData) => diff --git a/tests/render-new-session.test.tsx b/tests/render-new-session.test.tsx index db834bc..e62a550 100644 --- a/tests/render-new-session.test.tsx +++ b/tests/render-new-session.test.tsx @@ -5,6 +5,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { DraftShell, startDraftSession } from "../components/new-session.tsx"; +import { clearOpeningPrompt, readOpeningPrompt } from "../components/opening-prompt.ts"; /** * The sessionless draft view (/chat/new, owner 2026-09-13): "+ New session" @@ -25,10 +26,10 @@ function fakeFetch(id: string) { } describe("startDraftSession — the create-on-send contract", () => { - it("creates the session titled with the first message, returns the prompt redirect", async () => { + it("creates the session titled with the first message and returns a short path", async () => { const { f, calls } = fakeFetch("c_new"); const target = await startDraftSession("teach me orbital mechanics", f); - assert.equal(target, "/chat/c_new?prompt=teach%20me%20orbital%20mechanics"); + assert.equal(target, "/chat/c_new"); assert.equal(calls.length, 1); assert.equal(calls[0]!.url, "/api/chats"); assert.equal(calls[0]!.method, "POST"); @@ -41,6 +42,37 @@ describe("startDraftSession — the create-on-send contract", () => { assert.equal(calls.length, 1, "a single create call — the birth stamp is server-side"); }); + it("a long opening text stays out of the URL and is stashed for the chat page", async () => { + const mem = new Map(); + const fake = { + getItem: (k: string) => mem.get(k) ?? null, + setItem: (k: string, v: string) => { + mem.set(k, v); + }, + removeItem: (k: string) => { + mem.delete(k); + }, + }; + const holder = globalThis as { sessionStorage?: Storage }; + const prev = holder.sessionStorage; + Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: fake }); + try { + const { f } = fakeFetch("c_long"); + const text = `${"B2 First grammar item. ".repeat(1500)}end`; + const target = await startDraftSession(text, f); + assert.equal(target, "/chat/c_long"); + assert.equal(target!.includes("?"), false); + const stashed = readOpeningPrompt("c_long"); + assert.equal(stashed?.length, text.length); + assert.equal(stashed, text); + clearOpeningPrompt("c_long"); + assert.equal(readOpeningPrompt("c_long"), undefined); + } finally { + if (prev === undefined) delete holder.sessionStorage; + else Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: prev }); + } + }); + it("empty or whitespace text never creates anything", async () => { const { f, calls } = fakeFetch("never"); assert.equal(await startDraftSession("", f), null); @@ -188,7 +220,7 @@ describe("the practice draft (?mode=practice, 2026-10-04)", () => { return new Response(JSON.stringify({ id: "c9" }), { status: 201 }); }) as unknown as typeof fetch; const target = await startDraftSession("the parity chain", f, "practice"); - assert.equal(target, "/chat/c9?prompt=the%20parity%20chain"); + assert.equal(target, "/chat/c9"); assert.deepEqual(body, { title: "the parity chain", kind: "practice" }); }); From e851432f4c786d3ee57ee7aaf53439903ab043b7 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 26 Oct 2026 01:55:52 +0200 Subject: [PATCH 22/87] fix: separate worker completion from durable session delivery --- components/artifacts/dispatch-card.tsx | 4 +- docs/architecture.md | 28 +- src/agent/subagents/aborts.ts | 12 +- src/agent/subagents/runner.ts | 62 +++-- src/agent/tools/jobs.ts | 2 +- src/core/contracts/jobs.ts | 60 ++--- src/core/ports.ts | 2 + src/server/chat-handler.ts | 22 +- src/server/chats.ts | 25 +- src/server/jobs.ts | 337 +++++++++++++------------ src/server/pi/ui-stream.ts | 7 - src/store/jobs.ts | 1 + tests/chat-flow.test.ts | 4 +- tests/chatgpt-background.test.ts | 5 +- tests/demos.test.tsx | 10 +- tests/job-delivery-recovery.test.ts | 265 +++++++++++++++++++ tests/jobs.test.ts | 118 +++++---- tests/render-cards.test.tsx | 4 +- tests/subscription-background.test.ts | 4 +- 19 files changed, 646 insertions(+), 326 deletions(-) create mode 100644 tests/job-delivery-recovery.test.ts diff --git a/components/artifacts/dispatch-card.tsx b/components/artifacts/dispatch-card.tsx index eaa6db7..982f284 100644 --- a/components/artifacts/dispatch-card.tsx +++ b/components/artifacts/dispatch-card.tsx @@ -15,7 +15,7 @@ import { BrailleSpinner } from "@/components/braille-spinner"; * * job unknown → "dispatched" pulse (the jobs feed hasn't seen it yet) * running → live activity card (activity line + elapsed) - * done/error → quiet one-liner ("delivered" / "failed") — the artifact + * done/error → quiet one-liner ("finished" / "failed") — the artifact * itself arrives in the steer message right below, so the * box must not duplicate it. */ @@ -54,7 +54,7 @@ export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: J > {failed ? : } {label} - {failed ? "failed" : "delivered"} + {failed ? "failed" : "finished"}
); } diff --git a/docs/architecture.md b/docs/architecture.md index ed7d869..70594b1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -313,22 +313,28 @@ sequenceDiagram ### 5.1 The wake pipeline (Workbench → Classroom) -On sub-agent completion the runner builds a **steer message** — a user-role `UIMessage` whose text part is the model-facing result and whose `data-subagent-artifact` part carries the full result for the client (provider conversion drops data parts) — and hands it to the wake pipeline: +**Worker completion and session delivery are separate lifecycles.** Dispatch persists the tool input on the job before spawning. On completion, the runner saves its result, terminal worker status (`done`/`error`), and `delivery: {kind: "result", status: "pending"}` in one job-record update. It then queues the native custom **steer message** without awaiting delivery. Its content is model-facing text; details carry the UI artifact. Worker resources are released and the Background agents row disappears even if an unanswered question holds the result indefinitely. Dispatch cards say “finished,” not “delivered.” Only computing workers can be aborted; abort saves an independent pending abort notice. ```mermaid flowchart TB - R["job runner finishes sub-agent"] -->|"deliver(steer) — injectable seam"| ENQ["enqueue steer in per-chat queue (globalThis)"] - ENQ --> PUMP["pump(chatId) — one loop per chat (pumping set serializes)"] - PUMP --> GATE{"turn-free AND no pending question? (400ms poll)"} - GATE -->|no| PUMP - GATE -->|yes| REFRESH["re-fetch fresh record, re-check steerDelivered (claim race)"] - REFRESH --> RUN["turnRunner(chatId, record + steer) — the SAME handleChatRequest path"] - RUN -->|"200"| FIN["finalizeSteer → finalizeJob (done/error)
— status flips only after the wake turn persists"] - RUN -->|"409"| BACKOFF["backoff 500→1000→2000→5000ms → retry"] - CRASH["crash recovery: listJobs → recoverStaleJobs (stale >15min)
re-deliver results / respawn via setJobRespawn"] --> PUMP + R["save worker outcome + pending delivery; release worker"] --> ENQ["one registry entry per steer ID, including wake cargo"] + ENQ --> LIVE{"live agent and background consent?"} + LIVE -->|yes| STEER["pi steering at native boundaries; answer run takes parked results"] + LIVE -->|no| GATE["wait for turn-free, question-free, unpaused, consented session"] + GATE --> RUN["idle wake through the normal turn composition"] + RUN -->|"409"| BACKOFF["existing backoff 500→1000→2000→5000ms"] + BACKOFF --> GATE + RUN -->|"403"| HOLD["retain ownership until consent permits delivery"] + RUN -->|"other failed start"| QUIET["quiet pending; next learner run or restart recovery"] + RUN --> SAVE["canonical marker persistence, not merely HTTP 200"] + STEER --> SAVE + SAVE --> FIN["acknowledge delivery; worker outcome unchanged"] + CRASH["lazy recovery + live-agent registration restore known pending jobs"] --> ENQ ``` -Job status flips done/error only after the wake turn persists. Delivery is **exactly-once**: deterministic steer ids (`steer_`, `steer_probe_`), `steerDelivered` checks at every claim point, and `claimSteers` finalizing in-transcript steers instead of re-appending. The client adopts the wake turn through the same `turnInFlight` machinery as returning mid-stream (busy shimmer, composer gate, append-only `mergeAdopted` adoption) and renders steers as artifacts — never a bubble. +The per-chat registry retains ownership across live handoff, idle-wake flight, and 409/403 return; repeated submissions share one entry/promise. A deterministic marker ID in the **saved conversation** completes delivery. Streaming visibility alone does not. A failed bookkeeping acknowledgement cannot replay a saved marker, and later teaching/model failure does not reopen delivery or retry the accepted turn. Failed starts leave results durably pending without a new error UI or retry timer; the in-memory entry stays parked until a learner run or process restart. Normal question/dismissal waits and consent deferrals are not failures. + +Recovery reads saved job input before legacy transcript lookup. Missing research questions fail honestly without invoking the researcher. Legacy running jobs with saved results become finished workers with pending delivery; legacy terminal jobs without delivery metadata remain untouched, rather than guessing that old outcomes should be replayed. Pending result and abort notices are restored before a newly registered agent's first steering poll, even without a preceding jobs-feed read. Deleting a session discards its pending delivery obligations; only live turns and computing workers block deletion. These guarantees use the existing JSON storage and single-process delivery registry, not a transactional queue or a promise of exactly-once model execution. Sub-agents **verify, never one-shot**: the demo-builder headlessly executes its HTML in system Chrome before delivery; the visual-maker renders each SVG and looks at it (needs a vision-capable model — the `vision` slot in settings). diff --git a/src/agent/subagents/aborts.ts b/src/agent/subagents/aborts.ts index ed5b587..b533b8a 100644 --- a/src/agent/subagents/aborts.ts +++ b/src/agent/subagents/aborts.ts @@ -41,18 +41,16 @@ export function unregisterJobAbort(jobId: string): void { * the liveness gate for the stale-job sweep: a "running" record with a * live registration belongs to a loop this process owns (hung or slow, it * is the one truth — never re-spawn beside it); without one the record is - * an orphan of a process death and recovers immediately. The span is - * loop-start to delivery-completion: the runner's unregistering finally - * runs only after deliver() resolves, by which point finalizeJob has - * flipped the record — so absence of registration is unambiguous. */ + * an orphan of a process death and recovers immediately. The registration + * spans computation and cleanup only. Finished outcomes have independent, + * persisted delivery obligations; no worker waits for their consumption. */ export function isJobLive(jobId: string): boolean { return registry().has(jobId); } /** Abort a running job's loop. Returns false when no controller is live — - * the job's loop is not executing (crashed, or in the delivering-result - * phase where the runner has already returned); the caller then owns the - * whole abort on the record side. */ + * the job's loop is not executing. The endpoint separately refuses abort + * for a finished worker, even while its session delivery remains pending. */ export function fireJobAbort(jobId: string): boolean { const controller = registry().get(jobId); if (!controller) return false; diff --git a/src/agent/subagents/runner.ts b/src/agent/subagents/runner.ts index 41a3d40..55651dc 100644 --- a/src/agent/subagents/runner.ts +++ b/src/agent/subagents/runner.ts @@ -3,6 +3,7 @@ import type { SearchProvider } from "../../core/search.ts"; import { accumulateJobUsage, buildSubagentSteer, + isSubagentFailure, piUsageToStep, type DemoResult, type DemoToolInput, @@ -40,13 +41,13 @@ import { runVisualMaker } from "./visual-maker.ts"; * (the job tools' execute fires this via the spawn seam, fire-and-forget), * updating the job's activity line on every iteration. On completion the * runner builds the STEER MESSAGE (buildSubagentSteer) and hands it to - * `deliver`; the wake pipeline fires the server-side teacher turn and - * flips the job to done/error when the lesson has actually resumed. + * `deliver` without waiting. The worker's final outcome and pending + * delivery are saved together; the session owns delivery from then on. * * On failure the result is the failure shape (empty html/svg + "FAILED: …" * notes; research: failure brief) — and it STILL steers, because the - * teacher must learn the outcome. If delivery itself fails, the job is - * marked error (the learner's activity card shows it). + * teacher must learn the outcome. Failed delivery starts stay quietly + * pending and never rewrite the worker's computation outcome. */ /** @@ -67,9 +68,9 @@ export interface JobRunnerDeps { chatStore: ChatStore; jobStore: JobStore; /** - * Hand a finished job's steer to the wake pipeline — resolves when the - * steer is delivered (the wake turn completed and the job status - * flipped); rejects when delivery fails unrecoverably. + * Hand a finished job's steer to the session delivery registry. The + * promise tracks durable marker acceptance, but the worker never waits + * on it. Its persisted pending obligation survives process restart. */ deliver: (steer: SteerMessage) => Promise; /** Settings-resolved engine per role (model slots wave, 2026-09-20): @@ -96,8 +97,8 @@ export interface JobRunnerDeps { export interface JobRunner { /** Run one job's sub-agent loop to completion (background; never throws). * The tool input rides along from dispatch (the spawn fires mid-stream, - * before the tool part is persisted — only crash-recovery re-spawns - * resolve the input from the transcript). */ + * before the tool part is persisted). Recovery reads saved job input, + * with transcript fallback for legacy jobs. Delivery is not awaited. */ runJob(jobId: string, input?: JobToolInput): Promise; } @@ -270,14 +271,15 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner { } } - /** Resolve the job's tool input: dispatch passes it directly; only a - * crash-recovery re-spawn (no input) falls back to scanning the - * transcript (by then onFinish has long persisted the part). */ + /** Dispatch input wins, then the job's saved arguments. Legacy jobs + * without saved input fall back to the durable transcript, which may + * lack a call if the dispatching turn died before settlement. */ function resolveInput( job: JobRecord, input: JobToolInput | undefined, ): JobToolInput | undefined { if (input) return input; + if (job.input !== undefined) return job.input as JobToolInput; const chat = chatStore.getChat(job.chatId); if (!chat) return undefined; // Wave D: the record is native history — the scan reads the native @@ -295,27 +297,28 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner { } /** - * The tail of every job: store the result, build the steer, hand it to - * the wake pipeline. The pipeline flips the status when the lesson has - * actually resumed — until then the job reads "running" so the client's - * activity card keeps polling and re-syncs once, at the end. + * Save the computation outcome and session delivery obligation together. + * Worker liveness ends after cleanup, not after a lesson consumes the result. */ async function finishJob(jobId: string, result: ResearchResult | DemoResult | VisualResult | PdfResult): Promise { const job = jobStore.getJob(jobId); if (!job) return; - // Aborted mid-run or mid-delivery (abortJob flips the record before + // Aborted mid-run (abortJob flips the record before // the loop unwinds): no result write, no steer — the abort path owns // the record and queues the abort steer itself. if (job.status !== "running") return; - jobStore.updateJob(jobId, { result, activity: "delivering result" }); - const steer = buildSubagentSteer(job, result); - try { - await deliver(steer); - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - console.error(`[job ${jobId}] steer delivery failed:`, err); - jobStore.updateJob(jobId, { status: "error", error: reason, activity: "delivery failed" }); - } + const failed = isSubagentFailure(job.kind, result); + jobStore.updateJob(jobId, { + result, + status: failed ? "error" : "done", + activity: failed ? "failed" : "finished", + delivery: { kind: "result", status: "pending" }, + }); + // The saved result now belongs to the session. Do not keep the worker, + // abort registration, or temporary resources alive behind a question. + void deliver(buildSubagentSteer(job, result)).catch((err: unknown) => { + console.error(`[job ${jobId}] pending delivery:`, err); + }); } /** Chat-record + input resolution shared by all three runners. */ @@ -343,7 +346,12 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner { const { job } = ctx; const input = (passedInput ?? resolveInput(job, passedInput)) as ResearchToolInput | undefined; - const question = typeof input?.question === "string" && input.question.trim() ? input.question : "(no question)"; + if (!(typeof input?.question === "string" && input.question.trim())) { + jobStore.updateJob(jobId, { error: "research question unavailable" }); + await finishJob(jobId, { brief: "Research failed: the research question is unavailable.", sources: [] }); + return; + } + const question = input.question; const context = typeof input?.context === "string" && input.context.trim() ? input.context : undefined; const prompt = [ diff --git a/src/agent/tools/jobs.ts b/src/agent/tools/jobs.ts index 00a78ca..e3ed6a5 100644 --- a/src/agent/tools/jobs.ts +++ b/src/agent/tools/jobs.ts @@ -78,7 +78,7 @@ function dispatch( input: ResearchInput | DemoInput | VisualInput | PdfInput, ): string { const existing = ctx.jobStore.findJobByToolCallId(ctx.chatId, toolCallId); - const job = existing ?? ctx.jobStore.createJob({ kind, chatId: ctx.chatId, toolCallId }); + const job = existing ?? ctx.jobStore.createJob({ kind, chatId: ctx.chatId, toolCallId, input }); if (!existing) ctx.spawn(job, input); return `Dispatched (job ${job.id}). ${ack}`; } diff --git a/src/core/contracts/jobs.ts b/src/core/contracts/jobs.ts index 1b14519..144e974 100644 --- a/src/core/contracts/jobs.ts +++ b/src/core/contracts/jobs.ts @@ -21,6 +21,12 @@ export interface JobRecord { chatId: string; /** The tool part this job fulfills. */ toolCallId: string; + /** Dispatch-time arguments, saved before spawn. Older jobs resolve from history. */ + input?: unknown; + /** Session-owned delivery, independent of the worker's terminal outcome. + * Absent on legacy records: old terminal jobs must never be replayed. */ + delivery?: { kind: "result" | "abort"; status: "pending" | "delivered" }; + /** Worker computation only; a pending delivery does not keep it running. */ status: JobStatus; /** Latest human-readable activity line, e.g. `web_search: "sigma.js v3"`. */ activity: string; @@ -148,42 +154,32 @@ export function accumulateJobUsage( * sourced briefs) — tracking a live activity line in the job record. * ONE central client poll of GET /api/jobs?chatId=… (in Chat) drives every * job surface: the transcript dispatch cards (live activity by - * toolCallId, quiet one-liner after delivery), the sub-agents sidebar + * toolCallId, quiet one-liner after worker completion), the sub-agents sidebar * panel, and the single re-sync when a job settles. * - * 3. On completion the runner builds a STEER MESSAGE (buildSubagentSteer) - * and hands it to the wake pipeline (src/server/jobs.ts): a user-role - * result-carrying native marker — the content is what the MODEL needs - * (the research brief, the builder's notes), plus a data-subagent-artifact - * part with what the UI renders (the full result: brief + sources / - * SVG / demo HTML). Provider conversion drops data parts, so artifact - * payloads never enter the teacher's context. metadata.subagentResult - * identifies the job; the UI renders these as sub-agent result cards, - * NEVER as learner interjections. + * 3. Dispatch saves input on the job before spawn. On completion the + * runner saves result + terminal worker status + pending delivery in + * one job update, then queues buildSubagentSteer WITHOUT waiting for + * delivery. The native custom marker's content is model-facing text; + * details carry the full artifact payload for the UI. Worker resources + * are released even if a question keeps the result pending indefinitely. + * Only computing workers appear in Background agents or accept abort. * - * 4. The wake pipeline delivers the steer by firing a SERVER-SIDE teacher - * turn through the same turn composition as a client request - * (handleChatRequest) — write-ahead persist, turn liveness, extraction - * trigger: one code path. A per-chat serialized queue: a steer waits - * while a turn is in flight OR a question tool part is pending (an - * unanswered client-side tool call breaks provider conversion — the - * steer fires right after the learner's continuation), then fires; a 409 - * (a client turn raced in) retries. Delivery is idempotent per jobId, so - * a crashed wake can be re-entered by the stale-job recovery sweep. The - * job's status flips to done/error only AFTER the wake turn completes — - * a polling client that sees the flip re-syncs once and gets the - * finished record. + * 4. Session delivery owns one registry entry per deterministic steer ID, + * including while a wake is in flight or a 409/403 returns its cargo. + * A live agent takes it via pi steering; registration restores saved + * pending jobs before the answer run's first poll. Otherwise the idle + * wake uses the normal turn composition and respects pending questions, + * dismissal, and background consent. A saved transcript marker completes + * delivery; HTTP 200 or streamed visibility alone does not. * - * CONTINUATION COALESCING: the idle window above can starve — a - * quiz-chaining teacher ends every turn with a new pending check, so - * the gates never stay open long enough for a wake to fire. The fix: - * when a client turn wins the turn registry, the handler CLAIMS every - * queued, undelivered steer (jobs.ts claimSteers) and appends it after - * the learner's answer in that very turn — conversion-safe, because the - * answered quiz carries its output. Every answer flushes pending - * steers immediately; the wake pump remains for turns that end with no - * pending question. The pump re-checks steerDelivered right before - * firing, so a claim racing the pump converges to exactly one delivery. + * Failed starts remain quietly pending until a learner run or restart + * recovery, without new timers, visible delivery errors, or worker-status + * changes. Existing 409 backoff and consent deferral remain. Accepted + * turns are never automatically replayed. Legacy running records with a + * result become finished/pending; legacy terminal records with no + * delivery metadata remain untouched. Session deletion discards pending + * deliveries rather than treating them as running work. * * 5. No pending job parts ever exist, so the composer never gates on jobs * (the learner can interject mid-job; the steer queues behind their diff --git a/src/core/ports.ts b/src/core/ports.ts index ea67d93..b05d946 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -136,6 +136,8 @@ export interface CreateJobInit { toolCallId: string; /** First activity line (defaults to "starting"). */ activity?: string; + /** Saved on the job before its worker is spawned. */ + input?: unknown; } /** Mergeable fields — identity (id, kind, chatId, toolCallId, createdAt) is immutable. */ diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index 56132e9..74ed3c8 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -17,7 +17,7 @@ import { createExtractionPipeline } from "../agent/subagents/extraction.ts"; import type { ModelRole } from "../../config.ts"; import { deliverJobResult, - noteSteerVisible, + noteSteersDurable, reconcileUserSteers, registerLiveAgent, restoreUncommittedAutomatic, @@ -58,7 +58,7 @@ import { type TurnInput, } from "../core/input-events.ts"; import { steerDelivered } from "../core/contracts.ts"; -import type {CallUsage, SteerMessage} from "../core/contracts.ts"; +import type {CallUsage} from "../core/contracts.ts"; /** * One chat turn over the server-owned native record. Framework-free (plain @@ -259,7 +259,8 @@ export async function handleChatInput( chatId, ); const compactedRecord = stores.chatStore.getChat(chatId); - if (compactedRecord) record = compactedRecord; + if (!compactedRecord) return Response.json({ error: "Unknown chat" }, { status: 404 }); + record = compactedRecord; // The input's native appends (pure; the honest 400 for an answer with no // pending question call). Computed BEFORE beginTurn so a refused input @@ -329,9 +330,7 @@ export async function handleChatInput( // Visibility bookkeeping is not the canonical save. Its failure must // neither misreport a saved input as lost nor prevent the turn running. try { - for (const appended of stagedAutomatic ? [] : appends) { - if ((appended as { role?: string }).role === "custom") noteSteerVisible(appended as SteerMessage); - } + if (!stagedAutomatic) noteSteersDurable(chatId, merged); } catch (err) { console.error(`[chat ${chatId}] input visibility bookkeeping failed:`, err); } @@ -383,7 +382,11 @@ export async function handleChatInput( foregroundOwed: !callPurpose.background, purpose: callPurpose, scopeCurrent: (scope) => connectionScopeCurrent(conversation.provider, scope), - commitStaged: () => { stores.chatStore.saveMessages(chatId, merged); persistBaseCount = merged.length; }, + commitStaged: () => { + stores.chatStore.saveMessages(chatId, merged); + persistBaseCount = merged.length; + noteSteersDurable(chatId, merged); + }, restoreAutomatic: (automaticIds, retainedIds) => restoreUncommittedAutomatic(chatId, constructed, automaticIds, retainedIds), }); const agentOpts: AgentBuildOptions = { @@ -481,7 +484,7 @@ export async function handleChatInput( // run's first LLM call (the native replacement for continuation // coalescing). Settlement still cannot beat the flow below: it needs // a streamed response, which never happens inside the constructor. - registerLiveAgent(chatId, constructed); + registerLiveAgent(chatId, constructed, input.type !== "steers" && input.type !== "continue"); // Debug recorder run marker (owner, 2026-10-04): correlates the // JSONL event stream with the record's turns; no-op unless // LH_PROVIDER_EVENTS=1. @@ -522,12 +525,13 @@ export async function handleChatInput( // merely because usage/extraction/other settlement work failed. const persisted = stores.chatStore.getChat(chatId)?.messages ?? []; reconcileUserSteers(chatId, persisted); - settleSteers(chatId, persisted); + settleSteers(chatId, persisted, true); }, onFinish: (turnMessages, isAborted) => { // Wave B: the record write is the agent's own final state — // the new messages are the slice past the merged base. const stamped = persistTurn(stores.chatStore, chatId, persistBaseCount, turnMessages, isAborted); + noteSteersDurable(chatId, stores.chatStore.getChat(chatId)?.messages ?? stamped); // usage log append (idempotent per turn id) — the stats bar // refolds this on the client after the turn settles. The // provenance message is the turn's FIRST new assistant message. diff --git a/src/server/chats.ts b/src/server/chats.ts index b1976ca..2e6f2c9 100644 --- a/src/server/chats.ts +++ b/src/server/chats.ts @@ -6,6 +6,7 @@ import { graphRevision } from "./graph.ts"; import { stores } from "./stores.ts"; import { isTurnRunning } from "./turn-liveness.ts"; import { clearTurnFailure } from "./turn-runs.ts"; +import { discardChatDeliveries } from "./jobs.ts"; /** * Server-side chat entries for the transport layer (app/api/chats*, @@ -66,10 +67,7 @@ export type ChatDeleteOutcome = * the wind-down finds the chat gone at its merge and aborts with * nothing merged. */ -export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): Promise { - if (!stores.chatStore.getChat(chatId)) { - return { ok: false, status: 404, code: "not_found", error: "No such session" }; - } +function runningWorkBlocker(chatId: string): Extract | null { if (isTurnRunning(chatId)) { return { ok: false, @@ -89,6 +87,15 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P jobIds: runningJobs.map((job) => job.id), }; } + return null; +} + +export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): Promise { + if (!stores.chatStore.getChat(chatId)) { + return { ok: false, status: 404, code: "not_found", error: "No such session" }; + } + const blocker = runningWorkBlocker(chatId); + if (blocker) return blocker; if (extractionStatuses().some((run) => run.chatId === chatId)) { abortExtraction(chatId); // true cancellation — spend stops, nothing merges const deadline = Date.now() + WIND_DOWN_TIMEOUT_MS; @@ -105,11 +112,19 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P } } + // A result wake or a worker may have started during extraction wind-down. + // Recheck immediately before the synchronous purge, with no await gap. + const lateBlocker = runningWorkBlocker(chatId); + if (lateBlocker) return lateBlocker; + // THE SYNC BLOCK — no awaits below (see the doc comment). Order: // ledgers → jobs → the chat record. const stats = deleteChatTraces(stores.graphStore, chatId, mode); const jobsRemoved = stores.jobStore.deleteJobsForChat(chatId); - if (stores.chatStore.deleteChat(chatId)) clearTurnFailure(chatId); + if (stores.chatStore.deleteChat(chatId)) { + discardChatDeliveries(chatId); + clearTurnFailure(chatId); + } return { ok: true, mode, stats, jobsRemoved, graphRevision: graphRevision() }; } diff --git a/src/server/jobs.ts b/src/server/jobs.ts index 7424d41..98becef 100644 --- a/src/server/jobs.ts +++ b/src/server/jobs.ts @@ -1,7 +1,7 @@ /** * THE WAKE PIPELINE (contracts.ts "THE ASYNC PROTOCOL" §4) — server-side * steer delivery for finished sub-agent jobs. Wave C (2026-09-26): - * delivery rides pi's OWN follow-up queue when a run is live, and the + * delivery rides pi's OWN steering queue when a run is live, and the * idle wake otherwise. One registry, two vehicles. * * deliverJobResult(steer) — the runner calls this on job completion. @@ -40,15 +40,17 @@ * * settleSteers(chatId, persisted) — the handler calls this at turn * settlement (after unregisterLiveAgent): entries the run DELIVERED - * resolve + flip their jobs; entries that were handed to the dying + * resolve + acknowledge delivery; entries handed to the dying * run but never injected (stopper exit, abort, a missed final poll) * return to pending and the pump restarts — no stranding, no loss. * - * Crash recovery (recoverStaleJobs): a process restart can orphan jobs — - * "running" with a result (the wake never fired) or without one (the - * sub-agent loop itself died). The sweep re-delivers finished results - * (idempotently) and re-spawns unfinished loops, both via injected - * seams. It runs lazily off the /api/jobs poll, throttled. + * Worker status ends at computation completion; JobRecord.delivery owns + * the pending result or abort notice independently. Registry entries + * survive in-flight wakes, so duplicate submissions share one promise. + * Failed starts stay pending without timers or worker errors. Recovery + * restores known pending deliveries and re-spawns unfinished workers; + * registration also restores pending delivery before the first poll. + * Legacy terminal jobs without delivery metadata are never replayed. * * All mutable state lives on globalThis (the turn-liveness lesson: Next dev * gives each route entry its own module instances — a module-level Map would @@ -83,6 +85,11 @@ export type JobRespawn = (jobId: string) => void; interface QueuedSteer { steer: SteerMessage; + promise: Promise; + /** Wake cargo remains owned here, but must not steer into its own run. */ + waking?: boolean; + /** A failed start waits for a learner run or process restart, not a timer. */ + deferred?: boolean; resolve: () => void; reject: (reason: Error) => void; /** The live agent this marker was steered into (agent.steer) — set by @@ -166,18 +173,37 @@ function jobIdOf(steer: SteerMessage): string | null { return typeof details?.jobId === "string" ? details.jobId : null; } -/** Dock bookkeeping: the marker reached the learner's view — flip its job - * NOW instead of waiting for the delivering turn to settle. Called from - * BOTH visibility seams: the marker's message_end wire event (live steer - * injection — the card is on screen while the teacher keeps teaching) and - * the write-ahead persist in handleChatInput (the wake's input appends — - * the card is durable in the record). The registry's delivery promise - * still settles via settleSteers / the runner-200 path; finalizeJob is - * idempotent, so the later flip is a no-op. Owner ruling (2026-09-26): a - * delivered subagent leaves the background dock the moment its result is - * visible, not when the teacher's turn ends. */ -export function noteSteerVisible(steer: SteerMessage): void { - finalizeSteer(steer); +/** Call only after canonical conversation persistence. Streaming visibility + * is not durable acceptance. The saved marker wins over delivery-bookkeeping + * failures; acknowledging delivery never changes the worker's outcome. */ +export function noteSteersDurable(chatId: string, persisted: readonly LhMessage[]): void { + for (const message of persisted) { + if (message.role === "custom") finalizeSteer(message); + } + for (const entry of [...(state().queues.get(chatId) ?? [])]) { + if (persisted.some((message) => message.id === entry.steer.id)) completeEntry(chatId, entry); + } +} + +function removeEntry(chatId: string, entry: QueuedSteer): void { + const remaining = (state().queues.get(chatId) ?? []).filter((candidate) => candidate !== entry); + if (remaining.length > 0) state().queues.set(chatId, remaining); + else state().queues.delete(chatId); +} + +function completeEntry(chatId: string, entry: QueuedSteer): void { + finalizeSteer(entry.steer); + removeEntry(chatId, entry); + entry.resolve(); +} + +/** Deletion discards the session's obligations, never wakes a deleted chat. */ +export function discardChatDeliveries(chatId: string): void { + const s = state(); + const queue = s.queues.get(chatId) ?? []; + s.queues.delete(chatId); + s.userSteers.delete(chatId); + for (const entry of queue) entry.resolve(); } /** Finalize a delivered steer's job bookkeeping — job steers only. */ @@ -186,24 +212,30 @@ function finalizeSteer(steer: SteerMessage): void { if (jobId) finalizeJob(jobId); } -/** Fail a steer's job on unrecoverable delivery failure — job steers only. */ -function failSteer(steer: SteerMessage, err: Error): void { - const jobId = jobIdOf(steer); - if (jobId) failJob(jobId, err); +/** Queue one logical delivery per ID, including in-flight wake cargo. + * Job promises resolve on durable marker acceptance (or session deletion); + * failed starts stay pending. Non-job probe handoffs keep their existing + * wake-acceptance/error semantics. Workers never await this promise. */ +export function deliverJobResult(steer: SteerMessage): Promise { + const chatId = chatIdOf(steer); + const entry = queueSteer(chatId, steer); + void pump(chatId); + return entry.promise; } -/** Queue ANY steer (result, abort, probe handoff) through the pipeline. - * Resolves when the wake turn has completed; rejects when delivery fails - * unrecoverably. Never rejects for queueing itself. */ -export function deliverJobResult(steer: SteerMessage): Promise { - return new Promise((resolve, reject) => { - const s = state(); - const chatId = chatIdOf(steer); - const queue = s.queues.get(chatId) ?? []; - queue.push({ steer, resolve, reject }); - s.queues.set(chatId, queue); - void pump(chatId); - }); +function queueSteer(chatId: string, steer: SteerMessage): QueuedSteer { + const queue = state().queues.get(chatId) ?? []; + const existing = queue.find((entry) => entry.steer.id === steer.id); + if (existing) return existing; + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + // Recovery/registration may own an entry with no waiting caller. + void promise.catch(() => undefined); + const entry: QueuedSteer = { steer, promise, resolve, reject }; + queue.push(entry); + state().queues.set(chatId, queue); + return entry; } /** Hand every eligible pending marker to a live agent's steering queue @@ -227,7 +259,7 @@ function handOffEligible(chatId: string, agent: Agent): void { // remain deliverable, including the first poll of an answer run. if (automaticFollowUpAllowed(chatId, agent) && queue && queue.length > 0) { for (const entry of queue) { - if (entry.handedTo !== agent && entry.steer.customType !== "probe-handoff") { + if (!entry.waking && !entry.deferred && entry.handedTo !== agent && entry.steer.customType !== "probe-handoff") { agent.steer(entry.steer); entry.handedTo = agent; } @@ -254,8 +286,10 @@ function handOffEligible(chatId: string, agent: Agent): void { * markers are steered into the run right here. The settle seam cannot * beat the registration: settlement needs a streamed response, which * never happens inside the constructor call. */ -export function registerLiveAgent(chatId: string, agent: Agent): void { +export function registerLiveAgent(chatId: string, agent: Agent, allowDeferred = true): void { state().liveAgents.set(chatId, agent); + restorePendingDeliveries(chatId); + if (allowDeferred) for (const entry of state().queues.get(chatId) ?? []) entry.deferred = false; handOffEligible(chatId, agent); } @@ -326,7 +360,7 @@ export function restoreUncommittedAutomatic(chatId: string, agent: Agent, automa * unregisterLiveAgent, with the run's persisted messages. Two jobs: * * - entries whose marker the run DELIVERED (in-run follow-up injection — - * their ids are in the persisted slice) resolve and flip their jobs; + * their ids are in the persisted slice) resolve and acknowledge delivery; * - entries handed to the dying run but never injected (the question * stopper exited before the follow-up poll; an abort; a delivery that * raced the final poll) return to PENDING (handedTo cleared) and the @@ -335,7 +369,7 @@ export function restoreUncommittedAutomatic(chatId: string, agent: Agent, automa * This is what makes the live hand-off safe: pi owns no retry for a marker * stranded in a settled agent's queue, so the registry stays the truth and * the dead agent's copy is simply abandoned (GC'd with the agent). */ -export function settleSteers(chatId: string, persisted: { id?: string }[]): void { +export function settleSteers(chatId: string, persisted: { id?: string }[], failed = false): void { const queue = state().queues.get(chatId); if (!queue || queue.length === 0) return; const deliveredIds = new Set(persisted.map((m) => m.id).filter(Boolean)); @@ -346,6 +380,8 @@ export function settleSteers(chatId: string, persisted: { id?: string }[]): void entry.resolve(); } else { entry.handedTo = undefined; + entry.waking = false; + if (failed && jobIdOf(entry.steer)) entry.deferred = true; remaining.push(entry); } } @@ -373,16 +409,10 @@ export interface AbortOutcome { * runner registered it at loop start) — the in-flight model call * aborts, not just the next loop check. The runner unwinds silently * (its catch checks abortSignal.aborted; finishJob checks status). - * 2. DROP any queued result steer for this job (the delivering-result - * phase): resolve its entry without firing the wake. If the wake - * turn is ALREADY in flight (the pump shifted the entry), it's too - * late — the result steer lands, and the cancellation steer dedupes - * against it via steerDelivered (same jobId metadata). - * 3. FLIP the record to error "Aborted by learner" immediately — the - * poll reflects the user's action at once (the user chose - * error-with-reason over a distinct cancelled status). finalizeJob - * never overwrites a non-running status, so the flip is terminal no - * matter what the wake does. + * 2. REFUSE if computation already finished. Queued results belong to + * the session, not to an abortable background worker. + * 3. SAVE error "Aborted by learner" and a pending abort delivery together. + * Delivery acknowledgement never overwrites the worker's outcome. * 4. STEER the teacher: a one-line abort note (buildAbortSteer) through * the same wake pipeline, so the teacher's promise of a result * doesn't hang silently in the lesson. @@ -394,28 +424,18 @@ export function abortJob(jobId: string): AbortOutcome { const job = stores.jobStore.getJob(jobId); if (!job) return { ok: false, reason: "not_found" }; if (job.status !== "running") return { ok: false, reason: "not_running" }; - - fireJobAbort(jobId); // no-op when the loop already returned (delivering) - - // Drop any queued steers for this job (their deliver() resolves unfired). - // HANDED entries (already in a live run's follow-up queue) are past - // recall — same ruling as a fired wake: the result lands, and the abort - // note below dedupes against it via steerDelivered (same jobId). - const queue = state().queues.get(job.chatId); - if (queue) { - const remaining = queue.filter( - (e) => jobIdOf(e.steer) !== jobId || e.handedTo !== undefined, - ); - if (remaining.length === 0) state().queues.delete(job.chatId); - else state().queues.set(job.chatId, remaining); - for (const entry of queue) { - if (jobIdOf(entry.steer) === jobId && entry.handedTo === undefined) entry.resolve(); - } + if (job.result) { + normalizeLegacyResult(job); + return { ok: false, reason: "not_running" }; } - stores.jobStore.updateJob(jobId, { status: "error", error: "Aborted by learner", activity: "aborted" }); + fireJobAbort(jobId); + stores.jobStore.updateJob(jobId, { + status: "error", error: "Aborted by learner", activity: "aborted", + delivery: { kind: "abort", status: "pending" }, + }); void deliverJobResult(buildAbortSteer(job)).catch(() => { - /* deliverJobResult marks the job error on unrecoverable failure */ + /* The saved pending abort notice remains owned by the session. */ }); return { ok: true }; } @@ -470,18 +490,9 @@ async function pump(chatId: string): Promise { // Idempotent delivery: the transcript already carries these steers // (an in-run injection persisted, a wake completed, or a duplicate // deliver raced in). Resolve and flip without re-firing anything. - const stillPending = queue.filter((e) => !steerDelivered(record.messages, e.steer.id)); - for (const entry of queue) { - if (!stillPending.includes(entry)) { - finalizeSteer(entry.steer); - entry.resolve(); - } - } - if (stillPending.length === 0) { - s.queues.delete(chatId); - return; - } - s.queues.set(chatId, stillPending); + noteSteersDurable(chatId, record.messages); + const stillPending = (s.queues.get(chatId) ?? []).filter((entry) => !entry.waking && !entry.deferred); + if (stillPending.length === 0) { parked = true; return; } if (!automaticFollowUpAllowed(chatId, s.liveAgents.get(chatId))) { parked = true; return; } // VEHICLE 1 — THE LIVE RUN: pi's steering queue. The agent of the @@ -526,28 +537,17 @@ async function pump(chatId: string): Promise { // The runner is required — chat-handler sets it at init. If some // module instance missed the wiring, fail loudly rather than hang. if (!s.turnRunner) { - s.queues.delete(chatId); const err = new Error("wake pipeline: no turn runner wired (setTurnRunner)"); - for (const entry of stillPending) { - failSteer(entry.steer, err); - entry.reject(err); - } + for (const entry of stillPending) deferOrReject(chatId, entry, err); + parked = true; return; } // A live run may have delivered some markers while the gates were // closed — re-check: only still-missing markers ride the wake. - const fire = stillPending.filter((e) => !steerDelivered(fresh.messages, e.steer.id)); - for (const entry of stillPending) { - if (!fire.includes(entry)) { - finalizeSteer(entry.steer); - entry.resolve(); - } - } - if (fire.length === 0) { - s.queues.delete(chatId); - return; - } + noteSteersDurable(chatId, fresh.messages); + const fire = (s.queues.get(chatId) ?? []).filter((entry) => !entry.waking && !entry.deferred && !entry.handedTo); + if (fire.length === 0) return; // THE DISMISSAL PAUSE (owner design 2026-10-03, "A2"): the record // ends in a user dismissal — the session waits for the USER. Hold @@ -561,50 +561,37 @@ async function pump(chatId: string): Promise { return; } - // DEQUEUE-AT-FIRE (the Wave C lesson from the Wave B regression): - // the carried markers leave the registry BEFORE the turn runs — - // the wake run's OWN follow-up poll must never see its own cargo, - // and a 409 (client raced in) must be able to push them back. - s.queues.delete(chatId); + // Keep ownership through the wake. The flag excludes cargo from + // its own live-agent handoff while duplicate callers share it. + for (const entry of fire) entry.waking = true; const steers = fire.map((e) => e.steer); - // Fire the wake (every marker as the turn's input appends). A 409 = - // a client turn raced in between idle and firing — push the cargo - // back and retry after backoff (the racing turn's own settlement - // re-pumps if it beats us to the re-queue). + // Fire the wake with cargo still registered. A 409 releases its + // vehicle flag and retries after the existing backoff, without + // appending another registry entry. const status = await s.turnRunner(chatId, { type: "steers", steers }).catch((err: unknown) => { console.error(`[steer ${steers.map((x) => x.id).join(",")}] wake turn crashed:`, err); return -1; }); - if (status === 403) { - // Consent may have changed after the last gate. Preserve completed - // job/result ownership and wait for an explicit connection change. - const back = s.queues.get(chatId) ?? []; - back.push(...fire); - s.queues.set(chatId, back); - parked = true; - return; - } - if (status === 409) { - const back = s.queues.get(chatId) ?? []; - back.push(...fire); - s.queues.set(chatId, back); + const saved = stores.chatStore.getChat(chatId); + if (saved) noteSteersDurable(chatId, saved.messages); + const owned = fire.filter((entry) => s.queues.get(chatId)?.includes(entry)); + if (status === 403 || status === 409) { + for (const entry of owned) entry.waking = false; + if (status === 403) { parked = true; return; } await delay(RETRY_BACKOFF_MS[Math.min(attempt++, RETRY_BACKOFF_MS.length - 1)]!); continue; } attempt = 0; - - if (status === 200) { - for (const entry of fire) { - finalizeSteer(entry.steer); - entry.resolve(); - } - } else { - const err = new Error(`wake turn failed with status ${status}`); - for (const entry of fire) { - failSteer(entry.steer, err); - entry.reject(err); + for (const entry of owned) { + if (status === 200 && !jobIdOf(entry.steer)) { + // Preserve the non-job probe handoff's acceptance semantics. + completeEntry(chatId, entry); + } else if (status !== 200 || !isTurnRunning(chatId)) { + deferOrReject(chatId, entry, new Error(`wake turn did not persist steer (status ${status})`)); } + // An accepted live turn still owns its cargo until canonical save + // or settlement. HTTP 200 alone is not a durable marker receipt. } } } finally { @@ -614,7 +601,7 @@ async function pump(chatId: string): Promise { // a live run are NOT stragglers (settleSteers owns their resurrection) // — restarting for them would just park in waitForIdle forever. const q = state().queues.get(chatId) ?? []; - if (!parked && automaticFollowUpAllowed(chatId, state().liveAgents.get(chatId)) && q.some((e) => e.handedTo === undefined)) void pump(chatId); + if (!parked && automaticFollowUpAllowed(chatId, state().liveAgents.get(chatId)) && q.some((e) => e.handedTo === undefined && !e.waking && !e.deferred)) void pump(chatId); } } @@ -638,25 +625,55 @@ state().stopConnectionWatch = onSubscriptionChange(() => { } }); -/** Flip the job to its final status now that the lesson has resumed. */ -function finalizeJob(jobId: string): void { - const job = stores.jobStore.getJob(jobId); - if (!job || job.status !== "running") return; - const result = job.result; - const failed = result ? isSubagentFailure(job.kind, result as never) : true; - stores.jobStore.updateJob(jobId, { - status: failed ? "error" : "done", - activity: failed ? "failed" : "delivered", - }); +function deferOrReject(chatId: string, entry: QueuedSteer, error: Error): void { + entry.waking = false; + if (jobIdOf(entry.steer)) { + entry.deferred = true; + console.error(`[steer ${entry.steer.id}] delivery remains pending:`, error.message); + } else { + removeEntry(chatId, entry); + entry.reject(error); + } } -function failJob(jobId: string, err: Error): void { - // An aborted job stays aborted even when its (abort) steer - // delivery fails — the user's action is the terminal fact, not the - // delivery error. Non-running records are never overwritten here. - const job = stores.jobStore.getJob(jobId); - if (!job || job.status !== "running") return; - stores.jobStore.updateJob(jobId, { status: "error", error: err.message, activity: "delivery failed" }); +/** Acknowledge session delivery without changing the worker outcome. */ +function finalizeJob(jobId: string): void { + try { + let job = stores.jobStore.getJob(jobId); + if (!job) return; + if (!job.delivery) job = normalizeLegacyResult(job); + if (job.delivery?.status !== "pending") return; + stores.jobStore.updateJob(jobId, { delivery: { ...job.delivery, status: "delivered" } }); + } catch (error) { + // The conversation is already durable. A bookkeeping failure must + // never replay that marker or turn a successful worker into a failure. + console.error(`[job ${jobId}] delivery acknowledgement failed:`, error); + } +} + +function normalizeLegacyResult(job: JobRecord): JobRecord { + if (job.delivery || job.status !== "running" || !job.result) return job; + const failed = isSubagentFailure(job.kind, job.result as never); + return stores.jobStore.updateJob(job.id, { + status: failed ? "error" : "done", activity: failed ? "failed" : "finished", + delivery: { kind: "result", status: "pending" }, + }) ?? job; +} + +/** Restore known obligations only; old terminal records are never replayed. */ +function restorePendingDeliveries(chatId: string): void { + const record = stores.chatStore.getChat(chatId); + if (!record) return; + noteSteersDurable(chatId, record.messages); + for (const stored of stores.jobStore.listJobs(chatId)) { + const job = isJobLive(stored.id) ? stored : normalizeLegacyResult(stored); + if (job.delivery?.status !== "pending") continue; + const steer = job.delivery.kind === "abort" ? buildAbortSteer(job) + : job.result ? buildSubagentSteer(job, job.result as never) : undefined; + if (!steer) continue; + if (steerDelivered(record.messages, steer.id)) finalizeSteer(steer); + else queueSteer(chatId, steer); + } } /** @@ -669,7 +686,7 @@ function failJob(jobId: string, err: Error): void { async function waitForIdle(chatId: string): Promise { for (;;) { const record = stores.chatStore.getChat(chatId); - if (!record) return; + if (!record || !(state().queues.get(chatId) ?? []).some((entry) => !entry.waking && !entry.deferred && !entry.handedTo)) return; const blocked = isTurnRunning(chatId) || danglingQuestionOf(record.messages) !== null; if (!blocked) { await delay(GRACE_MS); @@ -699,9 +716,9 @@ const SWEEP_THROTTLE_MS = 30_000; * hung-but-alive loops beside themselves — the figure-maker incident, * 2026-09-18). A record WITH a live registration is provably executing * in THIS process — never touched, however old: a hung first model call - * (zero usage ever billed) stays honestly "running" in the dock until - * END kills it. The abort registration spans loop start to delivery - * completion, so a live registration can never belong to a dead run. + * stays honestly running until END kills it. Terminal workers with known + * pending delivery are restored independently; old terminal records without + * delivery metadata are never inferred to owe a delivery. */ /** Re-run a job's sub-agent loop — exposed for the sweep's tests (the * /api/jobs poll is its only production caller, disabled under NODE_ENV=test). */ @@ -713,18 +730,14 @@ export function recoverStaleJobs(): void { try { for (const chat of stores.chatStore.listChats()) { for (const job of stores.jobStore.listJobs(chat.id)) { - if (job.status !== "running") continue; - if (isJobLive(job.id)) continue; // provably executing here — never re-run beside it + if (isJobLive(job.id)) continue; // never re-run beside live computation if (!stores.chatStore.getChat(job.chatId)) continue; - if (isTurnRunning(job.chatId)) continue; // a wake turn may be delivering it right now - if (job.result) { - // The wake never fired (or crashed mid-flight): re-deliver. - const steer = buildSubagentSteer(job, job.result as never); - stores.jobStore.updateJob(job.id, { activity: "recovering: re-delivering" }); - void deliverJobResult(steer).catch(() => { - /* deliverJobResult marks the job error on failure */ - }); - } else { + if (isTurnRunning(job.chatId)) continue; + const recovered = normalizeLegacyResult(job); + if (recovered.delivery?.status === "pending") { + restorePendingDeliveries(job.chatId); + void pump(job.chatId); + } else if (job.status === "running" && !job.result) { // The sub-agent loop itself died with the process: re-spawn. if (!s.respawn) continue; stores.jobStore.updateJob(job.id, { activity: "recovering: re-running" }); diff --git a/src/server/pi/ui-stream.ts b/src/server/pi/ui-stream.ts index 2619ac4..40a831d 100644 --- a/src/server/pi/ui-stream.ts +++ b/src/server/pi/ui-stream.ts @@ -1,6 +1,4 @@ import type { Agent, AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core"; -import type { SteerMessage } from "../../core/contracts.ts"; -import { noteSteerVisible } from "../jobs.ts"; import { ToolCallIdUniquifier } from "./call-ids.ts"; import type { ChatGPTRequestCycle } from "../../agent/chatgpt-request-cycle.ts"; @@ -255,11 +253,6 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle { if (event.type === "message_end") { const m = event.message as { role?: string; stopReason?: string } | undefined; if (m?.role === "assistant" && m.stopReason === "aborted") sawAbort = true; - // Dock fix (owner ruling 2026-09-26): an injected steer marker's - // message_end = the card is visible to the learner — flip the - // job's bookkeeping now (the turn may keep teaching for minutes; - // settleSteers re-runs the flip as a no-op idempotently). - if (m?.role === "custom") noteSteerVisible(event.message as SteerMessage); } if (event.type === "agent_end") { // Persist/report before the terminal closes every view to new frames. diff --git a/src/store/jobs.ts b/src/store/jobs.ts index e8df50c..cea2da7 100644 --- a/src/store/jobs.ts +++ b/src/store/jobs.ts @@ -35,6 +35,7 @@ function createJob(init: CreateJobInit): JobRecord { kind: init.kind, chatId: init.chatId, toolCallId: init.toolCallId, + ...(init.input !== undefined ? { input: init.input } : {}), status: "running", activity: init.activity ?? "starting", createdAt: now, diff --git a/tests/chat-flow.test.ts b/tests/chat-flow.test.ts index 82ed82f..a1f3bca 100644 --- a/tests/chat-flow.test.ts +++ b/tests/chat-flow.test.ts @@ -522,7 +522,7 @@ describe("teacher chat flow (scripted model)", () => { const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-mid" }); jobIdMid = job.id; // The runner stores the result BEFORE delivery (finalizeJob reads it). - jobStore.updateJob(job.id, { result: { brief: "Mid-run brief.", sources: [] } }); + jobStore.updateJob(job.id, { status: "done", result: { brief: "Mid-run brief.", sources: [] }, delivery: { kind: "result", status: "pending" } }); const steer = buildSubagentSteer(job, { brief: "Mid-run brief.", sources: [] }); const delivery = deliverJobResult(steer); await new Promise((r) => setTimeout(r, 300)); // vehicle 1 hands off @@ -534,7 +534,7 @@ describe("teacher chat flow (scripted model)", () => { release2(); await drain(await res); await delivery; // resolves at settleSteers (the run persisted the marker) - assert.equal(jobFlippedMidTurn, "done", "the job flipped at the marker's message_end — WHILE the turn still streamed"); + assert.equal(jobFlippedMidTurn, "done", "worker completion is independent of the streaming teacher turn"); // Record shape: the marker sits BETWEEN completed turn units — after // the toolResult it must never split, adjacent to it. diff --git a/tests/chatgpt-background.test.ts b/tests/chatgpt-background.test.ts index 7bdf602..02bf717 100644 --- a/tests/chatgpt-background.test.ts +++ b/tests/chatgpt-background.test.ts @@ -43,7 +43,7 @@ for (const revokedAfterQueue of [false, true]) { stores.chatStore.saveSettings(chat.id, { model: { provider: "chatgpt", id: "gpt-5" } }); const job = stores.jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "scratch-job-call" }); const result = { brief: "Scratch completed job result", sources: [] }; - stores.jobStore.updateJob(job.id, { status: "running", result }); + stores.jobStore.updateJob(job.id, { status: "done", result, delivery: { kind: "result", status: "pending" } }); const marker = buildSubagentSteer(job, result); const contexts: string[] = []; const native = piModels().getModel("openai", "gpt-5")!; @@ -83,7 +83,8 @@ for (const revokedAfterQueue of [false, true]) { unregisterLiveAgent(chat.id); endTurn(chat.id); settleSteers(chat.id, persisted); await new Promise((resolve) => setImmediate(resolve)); assert.equal(wakes, 0, "no background spin or spend while consent is absent"); - assert.equal(stores.jobStore.getJob(job.id)!.status, "running", "delivery ownership must not be finalized or failed by deferral"); + assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending", "delivery ownership must not be finalized or failed by deferral"); + assert.equal(stores.jobStore.getJob(job.id)!.status, "done", "worker completion is independent of consent deferral"); assert.deepEqual(stores.jobStore.getJob(job.id)!.result, result); connection(true); await delivered; diff --git a/tests/demos.test.tsx b/tests/demos.test.tsx index 8ce0b15..494f66b 100644 --- a/tests/demos.test.tsx +++ b/tests/demos.test.tsx @@ -274,14 +274,14 @@ describe("dispatch card rendering (the tool-call ack box)", () => { assert.match(html, /Dispatched…/); }); - it("settles to a quiet delivered one-liner when done (artifact lives in the steer)", () => { + it("settles to a quiet finished one-liner when the worker is done", () => { const html = renderToStaticMarkup( , ); assert.match(html, /Research agent/); - assert.match(html, /delivered/); + assert.match(html, /finished/); assert.ok(!html.includes("web_search"), "no stale activity line once settled"); }); @@ -325,7 +325,7 @@ describe("dispatch card rendering (the tool-call ack box)", () => { assert.match(html, /pdf_render/); }); - it("pdf dispatch settles to the delivered one-liner", () => { + it("pdf dispatch settles to the finished one-liner", () => { const pdfJob: JobRecord = ({ id: "jp1", kind: "pdf", @@ -342,7 +342,7 @@ describe("dispatch card rendering (the tool-call ack box)", () => { , ); assert.match(html, /PDF reader/); - assert.match(html, /delivered/); + assert.match(html, /finished/); }); }); @@ -848,7 +848,7 @@ describe("demo/visual job runners", () => { const done = jobStore.getJob(job.id)!; assert.equal(done.status, "done"); - assert.equal(done.activity, "delivered", "the fake deliver flips status like the wake pipeline"); + assert.equal(done.activity, "finished", "the worker finishes independently of delivery"); const result = done.result as { title: string; html: string; tested: boolean; notes?: string }; assert.equal(result.title, "Compound interest"); assert.equal(result.html, GOOD_HTML); diff --git a/tests/job-delivery-recovery.test.ts b/tests/job-delivery-recovery.test.ts new file mode 100644 index 0000000..25618c7 --- /dev/null +++ b/tests/job-delivery-recovery.test.ts @@ -0,0 +1,265 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { after, afterEach, test } from "node:test"; +import { createFauxCore, fauxAssistantMessage } from "@earendil-works/pi-ai"; +import type { LhMessage } from "../src/core/native-messages.ts"; +import { buildSubagentSteer, type JobKind, type SteerMessage } from "../src/core/contracts.ts"; + +// Install isolation before importing any storage/configuration composition. +const directory = fs.mkdtempSync(path.join(os.tmpdir(), "lh-job-delivery-")); +process.env.LH_DATA_DIR = path.join(directory, "data"); +process.env.LH_AUTH_FILE = path.join(directory, "auth.json"); +fs.writeFileSync(process.env.LH_AUTH_FILE, "{}"); +const { stores } = await import("../src/server/stores.ts"); +const { jobTools } = await import("../src/agent/tools/jobs.ts"); +const { createJobRunner, researchModelOverride } = await import("../src/agent/subagents/runner.ts"); +const { isJobLive } = await import("../src/agent/subagents/aborts.ts"); +const { beginTurn, endTurn } = await import("../src/server/turn-liveness.ts"); +const { + deliverJobResult, discardChatDeliveries, noteSteersDurable, recoverStaleJobs, + registerLiveAgent, unregisterLiveAgent, settleSteers, setTurnRunner, +} = await import("../src/server/jobs.ts"); +const { activityFeed } = await import("../src/server/observability.ts"); +const { deleteChatSession } = await import("../src/server/chats.ts"); +const chats: string[] = []; +const result = { brief: "Fixture research result", sources: [] }; +const question = { + id: "question-message", role: "assistant", content: [{ type: "toolCall", id: "question-call", name: "quiz", arguments: {} }], + api: "openai-completions", provider: "faux", model: "faux", timestamp: 1, +} as unknown as LhMessage; +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +async function until(predicate: () => boolean): Promise { + const end = Date.now() + 8000; + while (!predicate()) { assert(Date.now() < end, "fixture condition timed out"); await delay(10); } +} +function sweep(): void { + const global = globalThis as { __lhWakePipeline?: { lastSweepAt: number } }; + if (global.__lhWakePipeline) global.__lhWakePipeline.lastSweepAt = 0; + recoverStaleJobs(); +} +function chat(blocked = false) { + const value = stores.chatStore.createChat("Isolated delivery fixture"); + chats.push(value.id); + if (blocked) stores.chatStore.saveMessages(value.id, [question]); + return value; +} +function pending(chatId: string) { + const value = stores.jobStore.createJob({ kind: "research", chatId, toolCallId: "fixture-call" }); + const job = stores.jobStore.updateJob(value.id, { + status: "done", result, activity: "finished", delivery: { kind: "result", status: "pending" }, + })!; + return { job, steer: buildSubagentSteer(job, result) }; +} +function agent() { + const handed: SteerMessage[] = []; + return { handed, value: { steer: (marker: SteerMessage) => handed.push(marker) } as unknown as Parameters[1] }; +} +function persist(chatId: string, steers: readonly LhMessage[]): void { + stores.chatStore.saveMessages(chatId, [...stores.chatStore.getChat(chatId)!.messages, ...steers]); + noteSteersDurable(chatId, steers); +} +afterEach(() => { + researchModelOverride.current = undefined; + for (const id of chats.splice(0)) { + unregisterLiveAgent(id); endTurn(id); discardChatDeliveries(id); + stores.jobStore.deleteJobsForChat(id); stores.chatStore.deleteChat(id); + } + setTurnRunner(async () => 500); +}); +after(() => fs.rmSync(directory, { recursive: true, force: true })); + +for (const [kind, input] of Object.entries({ + research: { question: "Saved research question", context: "Saved context" }, + demo: { title: "Fixture", brief: "Saved demo brief" }, + visual: { brief: "Saved figure brief" }, + pdf: { path: "fixture.pdf", question: "Saved PDF question" }, +})) test(`${kind}: dispatch saves input before spawn and does not overwrite it on repeated dispatch`, async () => { + const owner = chat(); + let spawns = 0; + const tools = jobTools({ chatId: owner.id, jobStore: stores.jobStore, spawn: (job) => { + spawns++; + assert.deepEqual(stores.jobStore.getJob(job.id)!.input, input); + } }); + await tools[kind]!.execute!("fixture-dispatch", input); + await tools[kind]!.execute!("fixture-dispatch", input); + assert.equal(spawns, 1); + assert.equal(stores.jobStore.listJobs(owner.id)[0]!.kind, kind as JobKind); +}); + +test("saved question survives absent transcript; runner ends while delivery is still pending", async () => { + const owner = chat(true); + const input = { question: "The dispatch turn was never saved", context: "Fixture context" }; + const job = stores.jobStore.createJob({ kind: "research", chatId: owner.id, toolCallId: "lost-turn", input }); + const faux = createFauxCore({}); + let seen = ""; + faux.setResponses([(context) => { seen = JSON.stringify(context.messages); return fauxAssistantMessage("Fixture brief"); }]); + researchModelOverride.current = { model: faux.getModel(), streamFn: faux.streamSimple }; + let delivery: Promise | undefined; + const runner = createJobRunner({ ...stores, deliver: (steer) => { delivery = deliverJobResult(steer); return delivery; } }); + await runner.runJob(job.id); + assert(seen.includes(input.question)); assert(seen.includes(input.context)); + assert.equal(isJobLive(job.id), false, "worker registration no longer spans delivery"); + assert.equal(stores.jobStore.getJob(job.id)!.status, "done"); + assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending"); + assert(!activityFeed().jobs.some((item) => item.id === job.id), "no running background-agent row"); + const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value); + assert.equal(next.handed.length, 1); + persist(owner.id, next.handed); unregisterLiveAgent(owner.id); settleSteers(owner.id, next.handed); endTurn(owner.id); + await delivery; +}); + +test("missing or blank research question records an honest failure without invoking a model", async () => { + for (const input of [undefined, { question: " " }]) { + const owner = chat(); + const job = stores.jobStore.createJob({ kind: "research", chatId: owner.id, toolCallId: "missing-question", input }); + const faux = createFauxCore({}); let calls = 0; + faux.setResponses([() => { calls++; return fauxAssistantMessage("must not run"); }]); + researchModelOverride.current = { model: faux.getModel(), streamFn: faux.streamSimple }; + const steers: SteerMessage[] = []; + await createJobRunner({ ...stores, deliver: async (steer) => { steers.push(steer); } }).runJob(job.id); + assert.equal(calls, 0); + assert.equal(stores.jobStore.getJob(job.id)!.error, "research question unavailable"); + assert.equal(stores.jobStore.getJob(job.id)!.status, "error"); + assert.match(steers[0]!.content, /^Research failed: the research question is unavailable\./); + assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending"); + } +}); + +test("repeated recovery behind a question yields one handed and one durable marker", async () => { + const owner = chat(true); const { job, steer } = pending(owner.id); + let wakes = 0; setTurnRunner(async () => { wakes++; return 500; }); + sweep(); sweep(); sweep(); + const first = deliverJobResult(steer); + assert.strictEqual(deliverJobResult(steer), first, "duplicate callers share one logical delivery"); + const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value); + assert.deepEqual(next.handed.map((message) => message.id), [steer.id]); + persist(owner.id, next.handed); unregisterLiveAgent(owner.id); settleSteers(owner.id, next.handed); endTurn(owner.id); + await first; + assert.equal(wakes, 0); + assert.equal(stores.chatStore.getChat(owner.id)!.messages.filter((message) => message.id === steer.id).length, 1); + assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "delivered"); +}); + +for (const refused of [409, 403]) test(`duplicate during a ${refused} wake retains one logical owner`, async () => { + const owner = chat(); const { steer } = pending(owner.id); + let release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); + let attempts = 0; + setTurnRunner(async (id, input) => { + assert.equal(input.type, "steers"); if (input.type !== "steers") return 500; + assert.equal(input.steers.length, 1); + attempts++; + if (attempts === 1) { await gate; return refused; } + persist(id, input.steers); return 200; + }); + const first = deliverJobResult(steer); + await until(() => attempts === 1); + assert.strictEqual(deliverJobResult(steer), first, "wake cargo is still in the registry"); + release(); + if (refused === 403) { + await delay(30); + const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value); + assert.equal(next.handed.length, 1); + persist(owner.id, next.handed); unregisterLiveAgent(owner.id); settleSteers(owner.id, next.handed); endTurn(owner.id); + } + await first; + assert.equal(stores.chatStore.getChat(owner.id)!.messages.filter((message) => message.id === steer.id).length, 1); +}); + +test("failed start stays quiet across sweeps, then joins a learner run", async () => { + const owner = chat(); const { job, steer } = pending(owner.id); + let attempts = 0; setTurnRunner(async () => { attempts++; return 503; }); + const delivery = deliverJobResult(steer); await until(() => attempts === 1); await delay(30); + sweep(); sweep(); await delay(900); + assert.equal(attempts, 1); + assert.equal(stores.jobStore.getJob(job.id)!.status, "done"); + assert.equal(stores.jobStore.getJob(job.id)!.error, undefined); + assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending"); + const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value); + assert.equal(next.handed.length, 1); + persist(owner.id, next.handed); unregisterLiveAgent(owner.id); settleSteers(owner.id, next.handed); endTurn(owner.id); + await delivery; +}); + +test("HTTP acceptance without a saved marker is not delivery completion or a retry trigger", async () => { + const owner = chat(); const { job, steer } = pending(owner.id); + let attempts = 0; + setTurnRunner(async () => { attempts++; assert(beginTurn(owner.id)); return 200; }); + const delivery = deliverJobResult(steer); await until(() => attempts === 1); await delay(30); + assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending"); + unregisterLiveAgent(owner.id); endTurn(owner.id); settleSteers(owner.id, [], true); + sweep(); await delay(900); assert.equal(attempts, 1); + const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value); + assert.equal(next.handed.length, 1); + persist(owner.id, next.handed); unregisterLiveAgent(owner.id); endTurn(owner.id); await delivery; +}); + +test("canonical save wins even if delivery bookkeeping fails; no marker replay", async () => { + const owner = chat(true); const { job, steer } = pending(owner.id); + const delivery = deliverJobResult(steer); + const update = stores.jobStore.updateJob; + stores.jobStore.updateJob = (id, patch) => { + if (id === job.id && patch.delivery?.status === "delivered") throw new Error("fixture acknowledgement write failure"); + return update(id, patch); + }; + try { persist(owner.id, [steer]); await delivery; } + finally { stores.jobStore.updateJob = update; } + let wakes = 0; setTurnRunner(async () => { wakes++; return 500; }); + sweep(); + assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "delivered"); + assert.equal(wakes, 0); + const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value); + assert.equal(next.handed.length, 0); unregisterLiveAgent(owner.id); endTurn(owner.id); +}); + +test("old running results become finished pending work; old terminal records are untouched", () => { + const owner = chat(true); + const old = stores.jobStore.createJob({ kind: "research", chatId: owner.id, toolCallId: "legacy-pending" }); + stores.jobStore.updateJob(old.id, { result }); + const terminal = stores.jobStore.createJob({ kind: "research", chatId: owner.id, toolCallId: "legacy-terminal" }); + stores.jobStore.updateJob(terminal.id, { status: "done", result }); + sweep(); + assert.equal(stores.jobStore.getJob(old.id)!.status, "done"); + assert.equal(stores.jobStore.getJob(old.id)!.delivery?.status, "pending"); + assert.equal(stores.jobStore.getJob(terminal.id)!.delivery, undefined); + const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value); + assert.deepEqual(next.handed.map((message) => message.id), [`steer_${old.id}`]); + unregisterLiveAgent(owner.id); endTurn(owner.id); +}); + +test("fresh process restores a pending delivery on registration without a jobs poll", () => { + const owner = chat(true); const { job, steer } = pending(owner.id); + const script = ` + const { stores } = await import('./src/server/stores.ts'); + const { registerLiveAgent, noteSteersDurable, unregisterLiveAgent } = await import('./src/server/jobs.ts'); + const { beginTurn, endTurn } = await import('./src/server/turn-liveness.ts'); + const handed = []; beginTurn(${JSON.stringify(owner.id)}); + registerLiveAgent(${JSON.stringify(owner.id)}, { steer: marker => handed.push(marker) }); + const record = stores.chatStore.getChat(${JSON.stringify(owner.id)}); + stores.chatStore.saveMessages(record.id, [...record.messages, ...handed]); + noteSteersDurable(record.id, handed); unregisterLiveAgent(record.id); endTurn(record.id); + console.log(JSON.stringify(handed.map(marker => marker.id))); + `; + const child = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], { + cwd: process.cwd(), encoding: "utf8", timeout: 15000, + env: { PATH: process.env.PATH, HOME: path.join(directory, "home"), NODE_ENV: "test", LH_DATA_DIR: process.env.LH_DATA_DIR, LH_AUTH_FILE: process.env.LH_AUTH_FILE }, + }); + assert.equal(child.status, 0, child.stderr); + assert.deepEqual(JSON.parse(child.stdout.trim()), [steer.id]); + assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "delivered"); + assert.equal(stores.chatStore.getChat(owner.id)!.messages.filter((message) => message.id === steer.id).length, 1); +}); + +test("deleting a session discards its pending deliveries without starting a turn", async () => { + const owner = chat(true); const { job, steer } = pending(owner.id); + let wakes = 0; setTurnRunner(async () => { wakes++; return 500; }); + const delivery = deliverJobResult(steer); + const outcome = await deleteChatSession(owner.id, "preserve-graph"); + assert(outcome.ok); + await delivery; await delay(450); + assert.equal(stores.jobStore.getJob(job.id), null); + assert.equal(stores.chatStore.getChat(owner.id), null); + assert.equal(wakes, 0); +}); diff --git a/tests/jobs.test.ts b/tests/jobs.test.ts index af9387a..161d4ce 100644 --- a/tests/jobs.test.ts +++ b/tests/jobs.test.ts @@ -51,18 +51,20 @@ import { runningTurn } from "../src/server/turn-runs.ts"; import { abortJob, deliverJobResult, - noteSteerVisible, + noteSteersDurable, + discardChatDeliveries, reconcileUserSteers, recoverStaleJobs, registerLiveAgent, setJobRespawn, - setTurnRunner, + setTurnRunner as setRawTurnRunner, + type TurnRunner, settleSteers, steerLiveChat, unregisterLiveAgent, } from "../src/server/jobs.ts"; -import { registerJobAbort, unregisterJobAbort } from "../src/agent/subagents/aborts.ts"; -import { beginTurn, endTurn } from "../src/server/turn-liveness.ts"; +import { isJobLive, registerJobAbort, unregisterJobAbort } from "../src/agent/subagents/aborts.ts"; +import { beginTurn, endTurn, isTurnRunning } from "../src/server/turn-liveness.ts"; import { persistTurn } from "../src/server/turn-service.ts"; import { chatStore } from "../src/store/chats.ts"; import { jobStore } from "../src/store/jobs.ts"; @@ -140,11 +142,13 @@ afterEach(() => { if (job && job.status === "running") { jobStore.updateJob(id, { status: "done", activity: "settled (fence)" }); } + if (job?.delivery?.status === "pending") jobStore.updateJob(id, { delivery: { ...job.delivery, status: "delivered" } }); unregisterJobAbort(id); } catch { /* already gone */ } } + for (const chatId of createdChats) discardChatDeliveries(chatId); // Late wakes from a prior test must be inert: a no-op respawn can't // increment anyone's counter, a no-op runner can't spawn real work. setJobRespawn(() => {}); @@ -454,16 +458,25 @@ function fakeDeliver() { steers, deliver: async (steer: SteerMessage) => { steers.push(steer); - const jobId = (steer.details as { jobId: string }).jobId; - const job = jobStore.getJob(jobId); - if (job && job.status === "running") { - const failed = job.result ? isSubagentFailure(job.kind, job.result as never) : true; - jobStore.updateJob(jobId, { status: failed ? "error" : "done", activity: failed ? "failed" : "delivered" }); - } + const job = jobStore.getJob((steer.details as { jobId: string }).jobId)!; + chatStore.saveMessages(job.chatId, [...chatStore.getChat(job.chatId)!.messages, steer]); + noteSteersDurable(job.chatId, [steer]); }, }; } +/** Fake wakes model canonical input acceptance, not just HTTP acknowledgement. */ +function setTurnRunner(run: TurnRunner): void { + setRawTurnRunner(async (chatId, input) => { + const status = await run(chatId, input); + if (status === 200 && input.type === "steers") { + const record = chatStore.getChat(chatId); + if (record) chatStore.saveMessages(chatId, [...record.messages, ...input.steers.filter((s) => !steerDelivered(record.messages, s.id))]); + } + return status; + }); +} + // ─── 3. dispatch: the tool call acks mid-turn and spawns ───────────────────── const RESEARCH_INPUT = { @@ -526,7 +539,7 @@ describe("dispatch (async protocol §2)", () => { const done = jobStore.getJob(job!.id)!; assert.equal(done.status, "done"); - assert.equal(done.activity, "delivered"); + assert.equal(done.activity, "finished"); // The steer carries the brief on its model-facing content; the // artifact payload rides details for the (UI) renderer. @@ -603,7 +616,7 @@ describe("research runner (async protocol §3)", () => { // The job is done, with the activity line from the tool call. const done = jobStore.getJob(job.id)!; assert.equal(done.status, "done"); - assert.equal(done.activity, "delivered", "the fake deliver flips the status like the pipeline"); + assert.equal(done.activity, "finished", "delivery does not own the worker status"); const result = done.result as ResearchResult; assert.equal(result.brief, BRIEF); assert.ok(result.sources.length > 0, "sources parsed from the brief"); @@ -746,7 +759,7 @@ describe("pdf runner (sources v0)", () => { const done = jobStore.getJob(job.id)!; assert.equal(done.status, "done"); - assert.equal(done.activity, "delivered"); + assert.equal(done.activity, "finished"); const result = done.result as PdfResult; assert.equal(result.path, PDF_FIXTURE); assert.match(result.brief, /integral rule/); @@ -827,7 +840,7 @@ describe("wake pipeline (src/server/jobs.ts)", () => { const job = jobStore.createJob({ kind, chatId: chat.id, toolCallId: `call-${jobStore.listJobs(chat.id).length}` }); trackJob(job.id); const result: ResearchResult = { brief: "Done: it works.", sources: [{ title: "s", url: "https://x.test" }] }; - jobStore.updateJob(job.id, { result, activity: "delivering result" }); + jobStore.updateJob(job.id, { result, status: "done", activity: "finished", delivery: { kind: "result", status: "pending" } }); const steer = buildSubagentSteer(job, result); return { chatId: chat.id, jobId: job.id, steer }; } @@ -882,7 +895,7 @@ describe("wake pipeline (src/server/jobs.ts)", () => { await waitFor(() => jobStore.getJob(jobId)!.status !== "running", 8000, "job flip after delivery"); assert.equal(jobStore.getJob(jobId)!.status, "done"); - assert.equal(jobStore.getJob(jobId)!.activity, "delivered"); + assert.equal(jobStore.getJob(jobId)!.delivery?.status, "delivered"); assert.ok(seen, "the turn runner fired"); assert.equal(seen.chatId, chatId); assert.equal(seen.steers.length, 1); @@ -920,7 +933,8 @@ describe("wake pipeline (src/server/jobs.ts)", () => { await delay(1200); assert.equal(fired, 0, "no wake while the quiz is pending"); - assert.equal(jobStore.getJob(jobId)!.status, "running", "job stays running while blocked"); + assert.equal(jobStore.getJob(jobId)!.status, "done", "worker is finished while delivery waits"); + assert.equal(jobStore.getJob(jobId)!.delivery?.status, "pending"); // The learner answers: the real tool result clears the dangling call. const record = chatStore.getChat(chatId)!; @@ -984,7 +998,7 @@ describe("wake pipeline (src/server/jobs.ts)", () => { await delay(1200); assert.equal(fired, 0, "no wake while the session sits dismissal-paused"); - assert.equal(jobStore.getJob(jobId)!.status, "running", "the delivery holds (queued, not lost)"); + assert.equal(jobStore.getJob(jobId)!.delivery?.status, "pending", "the delivery holds (queued, not lost)"); // The user's next message is the wake — its turn settles, and the // settlement re-pump lands the held delivery right after it. @@ -1056,7 +1070,8 @@ describe("Wave C: live steering handoff + settlement reconciliation", () => { await waitFor(() => handed.length === 1, 8000, "marker handed to the live agent"); await delay(1200); assert.equal(fired, 0, "no wake while the run is live"); - assert.equal(jobStore.getJob(jobId)!.status, "running", "job flips only at delivery"); + assert.equal(jobStore.getJob(jobId)!.status, "done", "worker finished before delivery"); + assert.equal(jobStore.getJob(jobId)!.delivery?.status, "pending"); // Settlement: the run persisted the injected marker. unregisterLiveAgent(chatId); @@ -1169,20 +1184,15 @@ describe("Wave C: live steering handoff + settlement reconciliation", () => { assert.equal(jobStore.getJob(jobId)!.status, "done"); }); - it("noteSteerVisible flips the job at the visibility seam — before any settle (owner ruling 2026-09-26)", async () => { - // The dock fix: the marker reaching the learner's view (wire - // message_end / write-ahead persist) flips the job NOW — the - // delivering turn may keep teaching for minutes. Idempotent with - // the settle-path finalize. + it("only a durable marker acknowledges delivery, independently of worker completion", () => { const { chatId, jobId, steer } = setup(); - assert.equal(jobStore.getJob(jobId)!.status, "running"); - noteSteerVisible(steer); - assert.equal(jobStore.getJob(jobId)!.status, "done", "flipped at the seam, no turn settled"); - noteSteerVisible(steer); // the settle-path flip re-runs — no-op assert.equal(jobStore.getJob(jobId)!.status, "done"); - // A probe-handoff (no jobId) is a harmless no-op. - const handoff = buildProbeHandoffSteer(chatId); - noteSteerVisible(handoff); + assert.equal(jobStore.getJob(jobId)!.delivery?.status, "pending"); + chatStore.saveMessages(chatId, [...chatStore.getChat(chatId)!.messages, steer]); + noteSteersDurable(chatId, [steer]); + noteSteersDurable(chatId, [steer]); + assert.equal(jobStore.getJob(jobId)!.delivery?.status, "delivered"); + assert.equal(jobStore.getJob(jobId)!.activity, "finished"); }); it("the idle wake carries EVERY pending marker in ONE turn", async () => { @@ -1208,17 +1218,25 @@ describe("Wave C: live steering handoff + settlement reconciliation", () => { }); - it("fails the job (error status) when the wake turn errors unrecoverably", async () => { - const { jobId, steer } = setup(); - setTurnRunner(async () => 500); - - await deliverJobResult(steer).then( - () => assert.fail("expected delivery to reject"), - () => undefined, - ); - const errored = jobStore.getJob(jobId)!; - assert.equal(errored.status, "error"); - assert.match(errored.error ?? "", /status 500/); + it("a failed start stays pending without changing the worker outcome", async () => { + const { chatId, jobId, steer } = setup(); + let attempts = 0; + setTurnRunner(async () => { attempts++; return 500; }); + const delivery = deliverJobResult(steer); + await waitFor(() => attempts === 1, 8000, "failed start attempted"); + await delay(1000); + assert.equal(attempts, 1, "no automatic retry loop"); + assert.equal(jobStore.getJob(jobId)!.status, "done"); + assert.equal(jobStore.getJob(jobId)!.error, undefined); + assert.equal(jobStore.getJob(jobId)!.delivery?.status, "pending"); + const handed: SteerMessage[] = []; + assert.ok(beginTurn(chatId)); + registerLiveAgent(chatId, { steer: (m: SteerMessage) => handed.push(m) } as never); + assert.deepEqual(handed.map((m) => m.id), [steer.id]); + unregisterLiveAgent(chatId); + settleSteers(chatId, [steer]); + endTurn(chatId); + await delivery; }); }); @@ -1554,14 +1572,14 @@ describe("abort (src/server/jobs.ts abortJob)", () => { } }); - it("aborting a delivering job drops the queued result steer and wakes with the abort note instead", async () => { + it("a finished worker with a queued result is no longer abortable", async () => { const chat = chatStore.createChat("Test: abort delivering job"); trackChat(chat.id); chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", content: "hello", timestamp: 1 } as never]); const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-abort3" }); trackJob(job.id); const result: ResearchResult = { brief: "Done: it works.", sources: [{ title: "s", url: "https://x.test" }] }; - jobStore.updateJob(job.id, { result, activity: "delivering result" }); + jobStore.updateJob(job.id, { result, status: "done", activity: "finished", delivery: { kind: "result", status: "pending" } }); // Block the pump's idle gates so the result steer sits QUEUED, not in flight. assert.ok(beginTurn(chat.id)); @@ -1573,18 +1591,18 @@ describe("abort (src/server/jobs.ts abortJob)", () => { void deliverJobResult(buildSubagentSteer(job, result)).catch(() => { /* dropped by the abort below */ }); - await waitFor(() => jobStore.getJob(job.id)!.activity === "delivering result", 8000, "queued"); + assert.equal(jobStore.getJob(job.id)!.delivery?.status, "pending"); const outcome = abortJob(job.id); - assert.ok(outcome.ok); - assert.equal(jobStore.getJob(job.id)!.status, "error"); - assert.equal(jobStore.getJob(job.id)!.error, "Aborted by learner"); + assert.deepEqual(outcome, { ok: false, reason: "not_running" }); + assert.equal(jobStore.getJob(job.id)!.status, "done"); + assert.equal(jobStore.getJob(job.id)!.error, undefined); endTurn(chat.id); // gates open — whatever is left in the queue fires await waitFor(() => fired.length > 0, 8000, "wake fired"); - assert.equal(fired.length, 1, "exactly one delivery — the result steer was dropped"); - assert.equal((fired[0]!.details as { aborted: boolean }).aborted, true, "the abort steer, not the result"); - assert.equal(jobStore.getJob(job.id)!.status, "error", "terminal after the wake"); + assert.equal(fired.length, 1, "exactly one result delivery"); + assert.equal(fired[0]!.customType, "subagent-steer"); + assert.equal(jobStore.getJob(job.id)!.status, "done"); }); it("outcome reasons: not_found for unknown, not_running for settled jobs", () => { diff --git a/tests/render-cards.test.tsx b/tests/render-cards.test.tsx index d5ae8d3..da0b3e1 100644 --- a/tests/render-cards.test.tsx +++ b/tests/render-cards.test.tsx @@ -156,9 +156,9 @@ describe("DispatchCard — the BYOK refusal tone (owner, 2026-09-22)", () => { assert.ok(!html.includes("dispatch-refused")); }); - it("a delivered job reads delivered, untouched by the wave", () => { + it("a finished worker reads finished, independently of delivery", () => { const html = renderToStaticMarkup(inJobs([job({ status: "done", error: undefined })])); - assert.ok(html.includes("delivered")); + assert.ok(html.includes("finished")); }); }); diff --git a/tests/subscription-background.test.ts b/tests/subscription-background.test.ts index e350009..c9cd7d3 100644 --- a/tests/subscription-background.test.ts +++ b/tests/subscription-background.test.ts @@ -42,7 +42,7 @@ for (const p of NATIVE_SUBSCRIPTIONS) for (const revokeAfterQueue of [false, tru const chat = stores.chatStore.createChat("Fixture mixed queue"); const model = { ...piModels().getModels(p.providerId)[0]!, provider: p.logicalId }; stores.chatStore.saveSettings(chat.id, { model: { provider: p.logicalId, id: model.id } }); const job = stores.jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "fixture-job-call" }); const result = { brief: "Fixture helper result", sources: [] }; - stores.jobStore.updateJob(job.id, { status: "running", result }); const steer = buildSubagentSteer(job, result); const contexts: string[] = []; + stores.jobStore.updateJob(job.id, { status: "done", result, delivery: { kind: "result", status: "pending" } }); const steer = buildSubagentSteer(job, result); const contexts: string[] = []; const agent = new Agent({ initialState: { model, messages: [{ id: "fixture-initial", role: "user", content: "Initial learner input", timestamp: 1 } as AgentMessage] }, convertToLlm: toLlmMessages, streamFn: () => { contexts.push(JSON.stringify(agent.state.messages)); const stream = new AssistantMessageEventStream(); const output = { role: "assistant" as const, provider: p.logicalId, api: model.api, model: model.id, content: [{ type: "text" as const, text: "Foreground response" }], stopReason: "stop" as const, timestamp: 2, usage: { input: 2, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 3, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } }; @@ -57,6 +57,6 @@ for (const p of NATIVE_SUBSCRIPTIONS) for (const revokeAfterQueue of [false, tru assert.equal(contexts.length, 1); assert(contexts[0]!.includes("Continue my learning")); assert(!contexts[0]!.includes(steer.id)); const persisted = persistTurn(stores.chatStore, chat.id, 0, agent.state.messages, false); unregisterLiveAgent(chat.id); endTurn(chat.id); settleSteers(chat.id, persisted); await new Promise((resolve) => setImmediate(resolve)); - assert.equal(wakes, 0); assert.equal(stores.jobStore.getJob(job.id)!.status, "running"); + assert.equal(wakes, 0); assert.equal(stores.jobStore.getJob(job.id)!.status, "done"); assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending"); connection(p.providerId, true); await delivered; assert.equal(wakes, 1); assert.equal(stores.jobStore.getJob(job.id)!.status, "done"); }); From 87b04cc86e60089ca37ff492ec4b0d4dbe9a1101 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 26 Oct 2026 01:55:57 +0200 Subject: [PATCH 23/87] fix: deliver Claude teaching prose through an output tool --- src/agent/subagents/extraction.ts | 21 +- src/agent/teacher-agent.ts | 38 +++- src/agent/tools/send-to-user.ts | 36 +++ src/core/coverage.ts | 52 ++--- src/core/native-messages.ts | 49 ++++ src/core/session-markdown.ts | 22 +- src/core/view.ts | 28 ++- tests/send-to-user.test.ts | 366 ++++++++++++++++++++++++++++++ 8 files changed, 550 insertions(+), 62 deletions(-) create mode 100644 src/agent/tools/send-to-user.ts create mode 100644 tests/send-to-user.test.ts diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts index aca547f..24cc975 100644 --- a/src/agent/subagents/extraction.ts +++ b/src/agent/subagents/extraction.ts @@ -28,7 +28,7 @@ import type { GraphStore } from "../../core/ports.ts"; import { emit } from "../../core/events.ts"; import { settleGraphLayout } from "../../core/layout-settle.ts"; import { batchRuns, coverageOf, messageViewLines } from "../../core/coverage.ts"; -import type { LhMessage } from "../../core/native-messages.ts"; +import { failedOutputCallIds, type LhMessage } from "../../core/native-messages.ts"; import { auditorGraphTools } from "../tools/graph.ts"; import { runSubmitAgent, healStringifiedArrays } from "./submit-agent.ts"; @@ -124,16 +124,20 @@ export function transcriptView(record: ChatRecord): string { } /** The extraction view under the teacher-prose ruling (owner, 2026-09-28): - * ONLY the teacher agent's text blocks — no learner messages, no - * thinking, no tool calls or results, no quiz blocks — each labeled with + * ONLY learner-facing teacher prose (text or send_to_user) — no learner + * messages, thinking, other tool calls/results, or quiz blocks — labeled with * its ACTUAL message id (owner, 2026-10-13): the view lines ARE * core/coverage.ts messageViewLines ("TEACHER [id] text"), the exact * lines the coverage fingerprints hash, so the extractor can anchor * every proposition to the passage it came from (the session submit's * required messageId). The shared markdown engine stays reserved for the * future user-facing export. */ -function proseView(messages: ReadonlyArray, title?: string): string { - const lines = messages.flatMap((message) => messageViewLines(message, message.id)); +function proseView( + messages: ReadonlyArray, + title?: string, + failedOutputCalls: ReadonlySet = failedOutputCallIds(messages), +): string { + const lines = messages.flatMap((message) => messageViewLines(message, message.id, failedOutputCalls)); const head = title && title.length > 0 ? `# ${title}\n\n` : ""; return head + lines.join("\n\n"); } @@ -1113,6 +1117,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti // stage runs (typed — the trigger skips, routes surface 503). refuseIfStagesUnready(); const covered = coverageOf(record.messages, graphStore.readCoverage(), chatId); + const failedOutputCalls = failedOutputCallIds(record.messages); if (covered.uncovered.length === 0) { console.info(`[extraction ${chatId}] nothing uncovered — no-op`); return { nodes: 0, chains: 0 }; @@ -1148,14 +1153,14 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti // fingerprints' messageViewLines ("TEACHER [id] text"), one // shared seam. const messages = batch.map((slot) => slot.message); - const view = proseView(messages, record.title); + const view = proseView(messages, record.title, failedOutputCalls); // The batch's teacher-prose ids — the ONLY anchors this // job's submit may name (validateSessionAnchors at the // submit seam refuses anything else: ids from other // batches — even real ones of this chat — other chats, // and non-prose messages). const batchIds = new Set( - messages.filter((m) => messageViewLines(m, m.id).length > 0).map((m) => m.id), + messages.filter((m) => messageViewLines(m, m.id, failedOutputCalls).length > 0).map((m) => m.id), ); setPhase(jobKey, "queued", "chat", undefined, part); const findings = await withExtractSlot(jobKey, async () => { @@ -1188,7 +1193,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti const batchStats = mergeFindings(chatId, findings, auditResult, { chatStore, graphStore }, { foldedAt, coverage: batch - .filter((slot) => messageViewLines(slot.message, slot.message.id).length > 0) + .filter((slot) => slot.tokens > 0) .map((slot) => ({ chatId, fp: slot.fp, tokens: slot.tokens, foldedAt })), }); // The layout ledger evolves with the merge (sync — the diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts index 08caa42..6fe6ac1 100644 --- a/src/agent/teacher-agent.ts +++ b/src/agent/teacher-agent.ts @@ -15,10 +15,11 @@ import { type TeacherToolContext, } from "./tools.ts"; import { practiceTools, type PracticeToolContext } from "./tools/practice.ts"; -import { healToolRecord } from "./tools/heal.ts"; +import { healToolRecord, withArgumentHeal } from "./tools/heal.ts"; +import { sendToUserTool } from "./tools/send-to-user.ts"; import { quizInputObject, quizInputProblems } from "../core/question-core.ts"; import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts"; -import { toLlmMessages, nativeInputCause } from "../core/native-messages.ts"; +import { toLlmMessages, nativeInputCause, PRACTICE_PROBLEM_TOOL } from "../core/native-messages.ts"; import type { ChatGPTRequestCycle } from "./chatgpt-request-cycle.ts"; import { usageWasAdmitted } from "./subscription-request-cycle.ts"; @@ -145,22 +146,27 @@ export interface AgentBuildOptions { * belt-and-suspenders HALF for the mixed batch (a question + other calls * in one message) that terminate-only semantics would let run on — the * learner cannot answer while a turn is in flight (per-chat 409), so a - * cleanly-posed question MUST always end the turn. maxTurns is gone + * cleanly-posed question MUST always end the turn. Practice's problem pose + * has the same wait-for-the-learner contract, including mixed output/problem + * batches; failed poses must still retry. maxTurns is gone * (owner ruling 2026-09-25: no maximum-agent-turns net on the teaching * turn). */ -function makeQuestionStopper() { +function makeQuestionStopper(kind: AgentKind) { return (context: { message: { content: Array<{ type: string; id?: string; name?: string }> }; toolResults: Array<{ toolCallId: string; isError?: boolean }>; }): boolean => { const called = new Set( context.message.content - .filter((c) => c.type === "toolCall" && c.name !== undefined && (QUESTION_TOOLS as readonly string[]).includes(c.name!)) + .filter((c) => c.type === "toolCall" && c.name !== undefined && ( + (QUESTION_TOOLS as readonly string[]).includes(c.name) || + (kind === "practice" && c.name === PRACTICE_PROBLEM_TOOL) + )) .map((c) => c.id!), ); if (called.size === 0) return false; - // Stop only when every question call of this turn resolved cleanly. + // Stop only when every question/problem pose resolved cleanly. for (const result of context.toolResults) { if (called.has(result.toolCallId) && result.isError) return false; } @@ -174,7 +180,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent // conversation-spec resolution as teaching); the cast keeps AgentKind // ("practice" is not a ModelRole) honest about that. const engine = opts.engine ?? piEngineFor(kind as "prober" | "teacher"); - const stopper = makeQuestionStopper(); + const stopper = makeQuestionStopper(kind); const systemPrompt = opts.promptSwitches ? composeInstructions(kind, opts.promptSwitches) : composedInstructions[kind]!; @@ -190,7 +196,16 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent systemPrompt, model: engine.model, thinkingLevel: engine.level, - tools, + // Conditional registry inclusion, like the graph-tool gates: when + // closed, neither the tool nor its usage instructions reach the model. + // Match Claude across providers (including OpenRouter), not the API + // transport. Explicit test registries remain caller-owned. + tools: [ + ...tools, + ...(opts.tools === undefined && engine.model.id.toLowerCase().includes("claude") + ? [withArgumentHeal(sendToUserTool)] + : []), + ], messages: opts.messages ?? [], }, streamFn: engine.streamFn, @@ -312,10 +327,9 @@ export function bareQuestionTools(): Record> { * from turn 1 of a kind:"practice" session — no prober→teacher split, * no plan tools, no question tools. Its ENTIRE registry is * problem + conclude (tools/practice.ts); the doctrine prompt - * (prompts/practice/) carries the method. The question-stop rule does - * not apply (problem rides terminate: true natively — - * QUESTION_TOOLS does not include it), so baseAgentOptions's stopper is - * a no-op here; it stays wired for uniformity. + * (prompts/practice/) carries the method. A successful problem pose ends + * the run, natively for a problem-only batch and via the shared stop hook + * for a mixed batch (for example, send_to_user + problem). */ export function createPracticeAgent(ctx: PracticeToolContext, opts: AgentBuildOptions = {}) { const tools = opts.tools ?? practiceTools(ctx); diff --git a/src/agent/tools/send-to-user.ts b/src/agent/tools/send-to-user.ts new file mode 100644 index 0000000..fb0e409 --- /dev/null +++ b/src/agent/tools/send-to-user.ts @@ -0,0 +1,36 @@ +import { Type } from "typebox"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { SEND_TO_USER_TOOL } from "../../core/native-messages.ts"; +import { serverExecute } from "../tool-execute.ts"; + +const sendToUserInput = Type.Object({ + message: Type.String({ + minLength: 1, + pattern: "\\S", + description: "The complete content to display to the learner. Markdown and LaTeX are supported.", + }), +}); + +/** Claude's documented verbatim mid-turn output channel. Availability is + * model-gated at the shared conversation-agent construction boundary; + * the native call remains in history, and the view renders its message. */ +export const sendToUserTool: AgentTool = { + name: SEND_TO_USER_TOOL, + label: SEND_TO_USER_TOOL, + description: + "## REQUIRED OUTPUT CHANNEL — ALWAYS USE `send_to_user`\n\n" + + "**ALL learner-facing messages MUST be sent using `send_to_user`.** This includes explanations, teaching, " + + "feedback, corrections, hints, introductions, progress messages, and final responses. " + + "There is no exception for short messages or the end of a turn.\n\n" + + "Ordinary response text may be converted into summarized thinking instead of reaching the learner. " + + "**Never rely on ordinary response text to communicate with the learner. Thinking is not delivery.**\n\n" + + "Put the complete message in `message`. Markdown and LaTeX are supported. " + + "Do not duplicate it in ordinary response text.\n\n" + + "Question content still belongs in the appropriate question tool; send any accompanying teaching or " + + "commentary through `send_to_user` before calling that tool.\n\n" + + "This tool does not pause or end your turn. Continue with other tools when needed. " + + "After delivering your final message, end the turn without additional ordinary response text.", + parameters: sendToUserInput, + executionMode: "sequential", + execute: serverExecute(SEND_TO_USER_TOOL, sendToUserInput, () => "Message received for display."), +}; diff --git a/src/core/coverage.ts b/src/core/coverage.ts index fe5b523..50c3ce0 100644 --- a/src/core/coverage.ts +++ b/src/core/coverage.ts @@ -1,9 +1,9 @@ /** * Extraction coverage — the pure core. THE TEACHER'S PROSE IS THE UNIT * (owner ruling, 2026-09-28): the extractor sees ONLY the teacher agent's - * text blocks — no learner messages, no thinking, no tool calls or - * results, no quiz blocks. Everything else is INVISIBLE to coverage: it - * neither opens nor joins a run, so answering a quiz or editing a learner + * prose — ordinary text blocks and explicit send_to_user messages. No + * learner messages, thinking, other tool calls/results, or quiz blocks. + * Everything else is INVISIBLE to coverage: it neither opens nor joins a run, so answering a quiz or editing a learner * message triggers nothing. "Covered" is by construction exactly "what * the extractor saw": the fingerprint (sha256 of a message's view lines) * and the extractor's prompt share ONE selection — the teacher prose. @@ -22,33 +22,27 @@ import { createHash } from "node:crypto"; import type { CoverageEntry } from "./contracts/graph.ts"; -import type { LhMessage } from "./native-messages.ts"; +import { failedOutputCallIds, userFacingTextOf, type LhMessage } from "./native-messages.ts"; export type { CoverageEntry }; -/** The user-facing text of a native message (string or text blocks). */ -function textOf(content: unknown): string[] { - if (typeof content === "string") return content.trim() ? [content] : []; - if (!Array.isArray(content)) return []; - return content - .filter((c): c is { type: "text"; text: string } => (c as { type?: string }).type === "text") - .map((c) => c.text) - .filter((t) => t.trim().length > 0); -} - /** * The extraction-relevant lines of ONE native message — the shared seam * between the extractor's prompt and messageFingerprint (coverage). * TEACHER PROSE ONLY (owner, 2026-09-28): an assistant message yields one - * line per text block; everything else (user messages, tool results, - * markers) yields NOTHING and is invisible to coverage. `mid` is the - * message id — part of the hash input, so the same prose in two messages + * line per learner-facing prose block; everything else (user messages, + * tool results, markers) yields NOTHING and is invisible to coverage. + * `mid` is the message id — part of the hash input, so the same prose in two messages * is two slots (per-message coverage). */ -export function messageViewLines(message: LhMessage, mid: string): string[] { +export function messageViewLines( + message: LhMessage, + mid: string, + failedOutputCalls: ReadonlySet = failedOutputCallIds([message]), +): string[] { const lines: string[] = []; if ((message as { role?: string }).role !== "assistant") return lines; - for (const text of textOf((message as { content: unknown }).content)) { + for (const text of userFacingTextOf((message as { content: unknown }).content, failedOutputCalls)) { lines.push(`TEACHER [${mid}] ${text.trim()}`); } return lines; @@ -62,9 +56,10 @@ export function messageViewLines(message: LhMessage, mid: string): string[] { */ export function teacherProseBlocks(messages: ReadonlyArray): string[] { const out: string[] = []; + const failedOutputCalls = failedOutputCallIds(messages); for (const message of messages) { if ((message as { role?: string }).role !== "assistant") continue; - for (const text of textOf((message as { content: unknown }).content)) out.push(text.trim()); + for (const text of userFacingTextOf((message as { content: unknown }).content, failedOutputCalls)) out.push(text.trim()); } return out; } @@ -79,9 +74,9 @@ export function viewTokens(lines: string[]): number { * teacher prose, with the message id as the tag). Changes exactly when * the teacher prose of the message changes — nothing else can re-open it. */ -export function messageFingerprint(message: LhMessage): string { +export function messageFingerprint(message: LhMessage, failedOutputCalls?: ReadonlySet): string { const h = createHash("sha256"); - h.update(JSON.stringify([(message as { role?: string }).role, messageViewLines(message, message.id)])); + h.update(JSON.stringify([(message as { role?: string }).role, messageViewLines(message, message.id, failedOutputCalls)])); return h.digest("hex"); } @@ -95,7 +90,12 @@ export interface CoverageSlot { /** Fingerprint a whole transcript, in order (used by the trigger, the run, * and the coverage view — one derivation everywhere). */ export function coverageSlots(messages: ReadonlyArray): CoverageSlot[] { - return messages.map((message) => ({ message, fp: messageFingerprint(message), tokens: viewTokens(messageViewLines(message, message.id)) })); + const failedOutputCalls = failedOutputCallIds(messages); + return messages.map((message) => ({ + message, + fp: messageFingerprint(message, failedOutputCalls), + tokens: viewTokens(messageViewLines(message, message.id, failedOutputCalls)), + })); } /** The covered-fingerprint set for one chat, from the ledger. */ @@ -131,16 +131,14 @@ export function coverageOf( let uncoveredTokens = 0; for (let i = 0; i < slots.length; i++) { const slot = slots[i]!; - if (messageViewLines(slot.message, slot.message.id).length === 0) continue; // invisible + if (slot.tokens === 0) continue; // invisible (including failed output calls) if (covered.has(slot.fp)) coveredTokens += slot.tokens; else { uncovered.push(i); uncoveredTokens += slot.tokens; } } - const visibleCount = slots.length - slots.filter( - (s) => messageViewLines(s.message, s.message.id).length === 0, - ).length; + const visibleCount = slots.filter((s) => s.tokens > 0).length; return { slots, uncovered, diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index 2983e4f..63f2720 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -98,6 +98,55 @@ export function asAgentMessages(messages: readonly LhMessage[]): AgentMessage[] return messages as unknown as AgentMessage[]; } +// ─── explicit learner-facing output ───────────────────────────────────────── + +export const SEND_TO_USER_TOOL = "send_to_user"; + +/** Read the output tool's message without changing the native call. Also + * accepts partial arguments while streaming; malformed/empty inputs carry + * no prose. This projection is independent of the currently selected model. */ +export function sendToUserText(block: unknown): string | undefined { + if (!block || typeof block !== "object") return undefined; + const call = block as { type?: unknown; name?: unknown; arguments?: unknown }; + if (call.type !== "toolCall" || call.name !== SEND_TO_USER_TOOL) return undefined; + if (!call.arguments || typeof call.arguments !== "object") return undefined; + const message = (call.arguments as { message?: unknown }).message; + return typeof message === "string" && message.trim() ? message : undefined; +} + +/** Learner-facing prose, in content order. Only the explicit output tool + * joins ordinary text here — thinking and all other tool inputs stay out. + * Shared by coverage/extraction and Markdown; native history stays intact. */ +export function userFacingTextOf(content: unknown, failedOutputCalls?: ReadonlySet): string[] { + if (typeof content === "string") return content.trim() ? [content] : []; + if (!Array.isArray(content)) return []; + const out: string[] = []; + for (const block of content) { + if (!block || typeof block !== "object") continue; + if (block.type === "toolCall" && failedOutputCalls?.has(block.id)) continue; + const text = block.type === "text" ? block.text : sendToUserText(block); + if (typeof text === "string" && text.trim()) out.push(text); + } + return out; +} + +/** Failed output calls remain retry context, not delivered teaching. + * Truncated calls can have valid-looking partial arguments. Failed/aborted + * generations do not execute their calls at all, so may have no result. */ +export function failedOutputCallIds(messages: readonly LhMessage[]): ReadonlySet { + const failed = new Set(); + for (const message of messages) { + if (message.role === "toolResult" && message.toolName === SEND_TO_USER_TOOL && message.isError) { + failed.add(message.toolCallId); + } else if (message.role === "assistant" && (message.stopReason === "error" || message.stopReason === "aborted")) { + for (const block of message.content) { + if (block.type === "toolCall" && block.name === SEND_TO_USER_TOOL) failed.add(block.id); + } + } + } + return failed; +} + // ─── id minting ────────────────────────────────────────────────────────────── /** The app-wide message id shape (same as the UIMessage-era mint). */ diff --git a/src/core/session-markdown.ts b/src/core/session-markdown.ts index 64a37b6..107e276 100644 --- a/src/core/session-markdown.ts +++ b/src/core/session-markdown.ts @@ -22,22 +22,15 @@ import type { LhMessage } from "./native-messages.ts"; import { + failedOutputCallIds, PRACTICE_CONCLUDE_TOOL, PRACTICE_PROBLEM_TOOL, problemInputForConclude, + SEND_TO_USER_TOOL, + userFacingTextOf, } from "./native-messages.ts"; import type { ToolResultMessage } from "@earendil-works/pi-ai"; -/** The user-facing text blocks of a native message (string or text parts). */ -function textOf(content: unknown): string[] { - if (typeof content === "string") return content.trim() ? [content] : []; - if (!Array.isArray(content)) return []; - return content - .filter((c): c is { type: "text"; text: string } => (c as { type?: string }).type === "text") - .map((c) => c.text) - .filter((t) => t.trim().length > 0); -} - /** The practice join context (owner-approved, 2026-10-06 export round): * practice tool RESULTS are acks — the statement, outcome, and verdict * all live in the assistant's call arguments, and the model solution @@ -48,6 +41,7 @@ export interface MarkdownContext { messages: ReadonlyArray; /** toolCallId → the call part's name + arguments. */ calls: Map | undefined }>; + failedOutputCalls?: ReadonlySet; } function markdownContext(messages: ReadonlyArray): MarkdownContext { @@ -66,7 +60,7 @@ function markdownContext(messages: ReadonlyArray): MarkdownContext { } } } - return { messages, calls }; + return { messages, calls, failedOutputCalls: failedOutputCallIds(messages) }; } /** solvedWithHints → "Solved with hints" — the outcome enum reads as prose. */ @@ -172,16 +166,16 @@ export function messageMarkdown( const parts: string[] = []; if (role === "user") { - for (const text of textOf((message as { content: unknown }).content)) { + for (const text of userFacingTextOf((message as { content: unknown }).content)) { parts.push(`### Learner${anchor}\n\n${text.trim()}`); } } else if (role === "assistant") { - for (const text of textOf((message as { content: unknown }).content)) { + for (const text of userFacingTextOf((message as { content: unknown }).content, ctx?.failedOutputCalls ?? failedOutputCallIds([message]))) { parts.push(`### Teacher${anchor}\n\n${text.trim()}`); } } else if (role === "toolResult") { const result = message as ToolResultMessage; - if (result.isError) return null; // retry context, not learning evidence + if (result.isError || result.toolName === SEND_TO_USER_TOOL) return null; // retry context or output ack, not learning evidence const { heading, body } = toolResultBody(result, ctx); parts.push(`### ${heading}${anchor}\n\n${body.join("\n\n")}`); } diff --git a/src/core/view.ts b/src/core/view.ts index 609de06..166d8d5 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -34,7 +34,7 @@ import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai"; import type { LhMarkerMessage, LhMessage } from "./native-messages.ts"; -import { modelSolutionForConclude, PRACTICE_CONCLUDE_TOOL, problemInputForConclude } from "./native-messages.ts"; +import { modelSolutionForConclude, PRACTICE_CONCLUDE_TOOL, problemInputForConclude, SEND_TO_USER_TOOL, sendToUserText } from "./native-messages.ts"; import { activeCompactionOf, type CompactionDetails } from "./compaction.ts"; import { QUESTION_TOOLS } from "./pending.ts"; import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts"; @@ -124,6 +124,14 @@ function lastSuccessfulPlanInit(parts: LhViewPart[]): number { * view messages. Pure: the same input always projects to the same list. */ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState): LhViewMessage[] { const out: LhViewMessage[] = []; + // Output calls become text rather than tool parts, so read settled + // failures up front: a failed call must retain the normal error path. + const outputErrors = new Map(); + for (const message of messages) { + if (message.role === "toolResult" && message.toolName === SEND_TO_USER_TOOL && message.isError) { + outputErrors.set(message.toolCallId, message); + } + } const lastAssistant = (): LhViewMessage | undefined => { const last = out[out.length - 1]; @@ -218,6 +226,24 @@ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState reasoning.push({ type: "reasoning", id: `${m.id}-r${reasoningN++}`, text: block.thinking, state: "done" }); } else if (block.type === "toolCall") { const part = toolPartOf(block, live, streamingMessage); + if (block.name === SEND_TO_USER_TOOL) { + // Failed/aborted generations never execute their calls; + // the provider error/abort UI already owns that outcome. + if (assistant.stopReason === "error" || assistant.stopReason === "aborted") continue; + const error = outputErrors.get(block.id); + if (error) applyResult(part, error); + if (part.state === "output-error") { + rest.push(part); + } else { + const text = sendToUserText(block); + if (text !== undefined) { + rest.push({ type: "text", id: `${m.id}-t${textN++}`, text, state: "done" }); + } + } + // No live tool card or acknowledgement: this channel is + // ordinary prose, including while its arguments stream. + continue; + } // The conclusion's reveal join (owner round, 2026-10-05): the // hero of a conclude card is the modelSolution committed at POSE // time — a record fold, enriched here so the card renders pure. diff --git a/tests/send-to-user.test.ts b/tests/send-to-user.test.ts new file mode 100644 index 0000000..beaeae3 --- /dev/null +++ b/tests/send-to-user.test.ts @@ -0,0 +1,366 @@ +/** Explicit Claude output: closed-network synthetic fixtures only. Native + * history stays native; UI, extraction, and Markdown read the same prose. */ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { after, describe, it } from "node:test"; +import { Value } from "typebox/value"; +import { + createFauxCore, fauxAssistantMessage, fauxText, fauxThinking, fauxToolCall, + type FauxResponseStep, +} from "@earendil-works/pi-ai"; +import type { PiEngine } from "../src/agent/models.ts"; +import type { AgentBuildOptions } from "../src/agent/teacher-agent.ts"; +import type { LhMessage } from "../src/core/native-messages.ts"; + +// Set the data/auth seams BEFORE importing app modules. Do not read local +// keys, recordings, or learner sessions, even if a dev server is running. +const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-send-to-user-")); +const originalEnv = process.env; +const originalFetch = globalThis.fetch; +process.env = { + ...(originalEnv.NODE_TEST_CONTEXT ? { NODE_TEST_CONTEXT: originalEnv.NODE_TEST_CONTEXT } : {}), + ...(originalEnv.NODE_OPTIONS ? { NODE_OPTIONS: originalEnv.NODE_OPTIONS } : {}), + NODE_ENV: "test", + LH_DATA_DIR: path.join(scratch, "data"), + LH_AUTH_FILE: path.join(scratch, "auth.json"), + HOME: path.join(scratch, "home"), +}; +globalThis.fetch = async () => { throw new Error("CLOSED: unexpected network request"); }; +after(() => { + process.env = originalEnv; + globalThis.fetch = originalFetch; + fs.rmSync(scratch, { recursive: true, force: true }); +}); + +const { sendToUserTool } = await import("../src/agent/tools/send-to-user.ts"); +const { createProberAgent, createTeacherAgent, createPracticeAgent, composeInstructions } = await import("../src/agent/teacher-agent.ts"); +const { SEND_TO_USER_TOOL, activeProblemOf, asAgentMessages, newUserMessage, sendToUserText, toLlmMessages, userFacingTextOf } = await import("../src/core/native-messages.ts"); +const { coverageOf, messageFingerprint, messageViewLines, teacherProseBlocks } = await import("../src/core/coverage.ts"); +const { projectView } = await import("../src/core/view.ts"); +const { sessionMarkdown } = await import("../src/core/session-markdown.ts"); +const { transcriptView } = await import("../src/agent/subagents/extraction.ts"); +const { runSubagent } = await import("../src/agent/subagents/pi-run.ts"); +const { persistTurn } = await import("../src/server/turn-service.ts"); +const { runPiTurn } = await import("../src/server/pi/ui-stream.ts"); +const { SessionModel } = await import("../components/session-core.ts"); +const { chatStore } = await import("../src/store/chats.ts"); +const { jobStore } = await import("../src/store/jobs.ts"); + +function scripted(provider: string, id: string, steps: FauxResponseStep[] = []) { + const faux = createFauxCore({ provider, models: [{ id }] }); + faux.setResponses(steps); + const engine: PiEngine = { model: faux.getModel(), streamFn: faux.streamSimple }; + return { engine, faux }; +} + +const chatId = chatStore.createChat("Synthetic output gate").id; +const factories = { + prober: (opts: AgentBuildOptions) => createProberAgent({ chatId, chatStore, jobStore, deliver: () => {}, spawn: () => {} }, opts), + teacher: (opts: AgentBuildOptions) => createTeacherAgent({ chatId, chatStore, jobStore, spawn: () => {} }, opts), + practice: (opts: AgentBuildOptions) => createPracticeAgent({ chatId, chatStore }, opts), +}; + +function outputCall(message: string | number | null = "An explanation.", id = "send-1") { + return { type: "toolCall" as const, id, name: SEND_TO_USER_TOOL, arguments: { message } }; +} +function assistant(content: unknown[], id = "a1"): LhMessage { + return { ...fauxAssistantMessage(""), content, id } as LhMessage; +} +function ack(callId = "send-1", isError = false): LhMessage { + return { + id: `result-${callId}`, role: "toolResult", toolCallId: callId, toolName: SEND_TO_USER_TOOL, + content: [{ type: "text", text: isError ? "Invalid tool input" : "Message received for display." }], + details: null, isError, timestamp: 1, + }; +} +function prose(messages: ReturnType): string[] { + return messages.filter((m) => m.role === "assistant").flatMap((m) => m.parts.filter((p) => p.type === "text").map((p) => p.text!)); +} + +describe("send_to_user model gate", () => { + for (const [provider, id, enabled] of [ + ["anthropic", "claude-opus-5-5", true], + ["anthropic", "claude-haiku-4-5-20251001", true], + ["openrouter", "anthropic/claude-opus-5.5", true], + ["openrouter", "anthropic/claude-sonnet-5.5", true], + ["amazon-bedrock", "us.anthropic.claude-opus-5-5-v1:0", true], + ["fixture", "CLAUDE-fixture", true], + ["openrouter", "z-ai/glm-5.3-flash", false], + ["openai", "gpt-4o-mini", false], + ["anthropic", "opaque-alias", false], + ] as const) { + it(`${provider}/${id}: ${enabled ? "includes" : "omits"} tool and description in all conversation agents`, () => { + const { engine } = scripted(provider, id); + for (const [kind, factory] of Object.entries(factories)) { + const agent = factory({ engine }); + const output = agent.state.tools.find((tool) => tool.name === SEND_TO_USER_TOOL); + assert.equal(Boolean(output), enabled, kind); + if (enabled) { + assert.equal(output!.description, sendToUserTool.description); + assert.equal(typeof output!.prepareArguments, "function", "standard argument healing is wired"); + } + assert.equal(agent.state.systemPrompt, composeInstructions(kind as keyof typeof factories)); + assert.ok(!agent.state.systemPrompt.includes(SEND_TO_USER_TOOL), "no added prompt instructions"); + } + }); + } + + it("explicitly injected teacher/practice test registries remain unchanged", () => { + const { engine } = scripted("anthropic", "claude-opus-5-5"); + for (const factory of [factories.teacher, factories.practice]) { + assert.deepEqual(factory({ engine, tools: {} }).state.tools, []); + } + }); + + it("background agents do not acquire a learner-output tool", async () => { + let calls = 0; + const { engine } = scripted("anthropic", "claude-opus-5-5", [(context) => { + calls++; + assert.ok(!context.messages.some((m) => m.role === "system" && m.toolsAdded?.some((t) => t.name === SEND_TO_USER_TOOL))); + return fauxAssistantMessage("Synthetic background result."); + }]); + await runSubagent([], { role: "researcher", engine, systemPrompt: "Synthetic fixture.", prompt: "Finish.", maxTurns: 2 }); + assert.equal(calls, 1); + }); +}); + +describe("send_to_user mechanics", () => { + it("requires nonblank text and acknowledges without echoing or terminating", async () => { + assert.equal(sendToUserTool.name, SEND_TO_USER_TOOL); + for (const input of [{}, { message: "" }, { message: " \n\t" }, { message: 3 }, { message: null }]) { + assert.equal(Value.Check(sendToUserTool.parameters, input), false); + } + const message = "**A full explanation**\n\n$$a = b$$"; + assert.equal(Value.Check(sendToUserTool.parameters, { message }), true); + const result = await sendToUserTool.execute("send-1", { message }); + assert.ok(!result.isError); + assert.ok(!result.terminate); + assert.ok(!JSON.stringify(result).includes(message)); + assert.deepEqual(result.content, [{ type: "text", text: "Message received for display." }]); + }); + + it("only extracts the output tool's nonblank message, without modifying it", () => { + const message = " **Keep spacing and math** $x$\n"; + assert.equal(sendToUserText(outputCall(message)), message); + for (const block of [null, {}, { type: "text", text: "ordinary" }, outputCall(2), outputCall(" "), { ...outputCall(), name: "other" }]) { + assert.equal(sendToUserText(block), undefined); + } + assert.deepEqual(userFacingTextOf([null, fauxThinking("Not lesson prose."), fauxText("First"), outputCall(message), fauxToolCall("quiz", { question: "Q?" })]), ["First", message]); + }); +}); + +describe("send_to_user transcript projection", () => { + it("renders in place as prose, with thinking unchanged and no tool/ack duplication", () => { + const messages = [assistant([ + fauxText("Before."), outputCall("**The lesson.** $x = 1$"), + fauxThinking("Private reasoning."), fauxToolCall("quiz", { question: "Q?" }, { id: "quiz-1" }), + fauxText("After."), + ]), ack()]; + const before = structuredClone(messages); + const view = projectView(messages); + assert.deepEqual(view[0]!.parts.map((p) => p.type), ["step-start", "reasoning", "text", "text", "tool-quiz", "text"]); + assert.deepEqual(prose(view), ["Before.", "**The lesson.** $x = 1$", "After."]); + assert.equal(view[0]!.parts.find((p) => p.type === "reasoning")!.text, "Private reasoning."); + assert.deepEqual(view[0]!.parts.filter((p) => p.type === "text").map((p) => p.id), ["a1-t0", "a1-t1", "a1-t2"]); + assert.deepEqual(messages, before, "projection never rewrites native content"); + assert.deepEqual(projectView(JSON.parse(JSON.stringify(messages))), view, "reload is identical, without consulting a provider gate"); + }); + + it("streams available arguments as replacing prose, never as a live tool card", () => { + const live = { inFlight: true, streamingIds: new Set(["a1"]), executions: new Map() }; + for (const text of ["A", "A growing", "A growing explanation."]) { + const view = projectView([assistant([outputCall(text)])], live); + assert.deepEqual(prose(view), [text]); + assert.ok(!view[0]!.parts.some((p) => p.type.startsWith("tool-"))); + } + const empty = projectView([assistant([{ ...outputCall(), arguments: {} }])], live); + assert.deepEqual(prose(empty), []); + assert.ok(!empty[0]!.parts.some((p) => p.type.startsWith("tool-"))); + }); + + it("malformed messages contain no prose; failures keep the existing error path live and after reload", () => { + assert.deepEqual(prose(projectView([assistant([outputCall(3)])])), []); + const messages = [assistant([outputCall("Incomplete teaching.")]), ack("send-1", true)]; + const settled = projectView(messages); + assert.deepEqual(prose(settled), []); + assert.equal(settled[0]!.parts[1]!.type, "tool-send_to_user"); + assert.equal(settled[0]!.parts[1]!.state, "output-error"); + const live = projectView(messages.slice(0, 1), { + inFlight: true, streamingIds: new Set(["a1"]), + executions: new Map([["send-1", { isError: true, errorText: "Invalid tool input" }]]), + }); + assert.deepEqual(prose(live), []); + assert.equal(live[0]!.parts[1]!.state, "output-error"); + }); +}); + +describe("send_to_user extraction and Markdown", () => { + it("selects output prose once and in order, preserving old fingerprints", () => { + const message = assistant([fauxText("First."), outputCall("Second."), fauxThinking("Hidden."), outputCall("Third.", "send-2")]); + const messages = [message, ack(), ack("send-2")]; + assert.deepEqual(messageViewLines(message, "a1"), ["TEACHER [a1] First.", "TEACHER [a1] Second.", "TEACHER [a1] Third."]); + assert.deepEqual(teacherProseBlocks(messages), ["First.", "Second.", "Third."]); + assert.equal(coverageOf(messages, [], "fixture").uncoveredCount, 1); + const md = sessionMarkdown(messages, { anchors: false }); + assert.equal(md, "### Teacher\n\nFirst.\n\n### Teacher\n\nSecond.\n\n### Teacher\n\nThird."); + const record = chatStore.createChat("Synthetic extraction"); + assert.equal(transcriptView({ ...record, messages }), "# Synthetic extraction\n\nTEACHER [a1] First.\n\nTEACHER [a1] Second.\n\nTEACHER [a1] Third."); + const legacy = assistant([fauxText("Old text."), fauxThinking("Old thinking.")], "legacy"); + const oldFingerprint = createHash("sha256").update(JSON.stringify(["assistant", ["TEACHER [legacy] Old text."]])).digest("hex"); + assert.equal(messageFingerprint(legacy), oldFingerprint, "no re-extraction/migration of legacy prose"); + }); + + it("does not extract/export failed output calls, including partial calls from truncated responses", () => { + const message = assistant([fauxText("Keep this."), outputCall("Do not treat this as delivered.")]); + const messages = [message, ack("send-1", true)]; + assert.deepEqual(teacherProseBlocks(messages), ["Keep this."]); + assert.equal(sessionMarkdown(messages, { anchors: false }), "### Teacher\n\nKeep this."); + const record = chatStore.createChat("Failed output fixture"); + assert.ok(!transcriptView({ ...record, messages }).includes("Do not treat")); + const coverage = coverageOf([assistant([outputCall()]), ack("send-1", true)], [], "fixture"); + assert.equal(coverage.uncoveredCount, 0); + assert.equal(coverage.uncoveredTokens, 0); + }); +}); + +describe("send_to_user interrupted generations", () => { + for (const stopReason of ["error", "aborted"] as const) { + it(`${stopReason} without a tool result never becomes delivered teaching; ordinary text stays unchanged`, () => { + const message = { ...assistant([fauxText("Keep ordinary text."), outputCall("Unfinished output.")]), stopReason }; + const messages = [message]; + assert.deepEqual(prose(projectView(messages)), ["Keep ordinary text."]); + assert.deepEqual(prose(projectView(JSON.parse(JSON.stringify(messages)))), ["Keep ordinary text."]); + assert.deepEqual(messageViewLines(message, message.id), ["TEACHER [a1] Keep ordinary text."]); + assert.deepEqual(teacherProseBlocks(messages), ["Keep ordinary text."]); + assert.equal(sessionMarkdown(messages, { anchors: false }), "### Teacher\n\nKeep ordinary text."); + const onlyOutput = { ...assistant([outputCall("Unfinished output.")]), stopReason }; + assert.equal(coverageOf([onlyOutput], [], "fixture").uncoveredCount, 0); + }); + } +}); + +describe("send_to_user native flow (offline)", () => { + it("output alone continues the loop, so the model receives its native acknowledgement", async () => { + const { engine, faux } = scripted("anthropic", "claude-opus-5-5", [ + fauxAssistantMessage([outputCall("Read this first.")], { stopReason: "toolUse" }), + (context) => { + assert.ok(context.messages.some((m) => m.role === "toolResult" && m.toolName === SEND_TO_USER_TOOL)); + return fauxAssistantMessage([fauxText("Final plain reply.")]); + }, + ]); + const agent = createTeacherAgent(undefined, { engine }); + await agent.prompt("Synthetic request."); + assert.equal(faux.state.callCount, 2); + }); + + for (const outputFirst of [true, false]) { + it(`output + practice problem stops after the pose (${outputFirst ? "output first" : "problem first"})`, async () => { + const output = outputCall("Try this practice problem."); + const problem = fauxToolCall("problem", { + statement: "What is two plus two?", mode: "recall", modelSolution: "Four.", + }, { id: "problem-1" }); + const { engine, faux } = scripted("anthropic", "claude-opus-5-5", [ + fauxAssistantMessage(outputFirst ? [output, problem] : [problem, output], { stopReason: "toolUse" }), + fauxAssistantMessage("Premature continuation must not run."), + ]); + const agent = createPracticeAgent({ chatId, chatStore }, { engine }); + await agent.prompt("Pose a synthetic problem."); + assert.equal(faux.state.callCount, 1, "wait for the learner after a successful mixed-batch pose"); + const results = agent.state.messages.filter((m) => m.role === "toolResult"); + assert.equal(results.length, 2); + assert.ok(results.every((m) => !m.isError)); + }); + } + + for (const stopReason of ["error", "aborted"] as const) { + it(`${stopReason} mixed output/problem generation never executes or activates the pose`, async () => { + const { engine, faux } = scripted("anthropic", "claude-opus-5-5", [ + fauxAssistantMessage([ + outputCall("Undelivered introduction."), + fauxToolCall("problem", { + statement: "What is two plus two?", mode: "recall", modelSolution: "Four.", + }, { id: "unfinished-problem" }), + ], { stopReason }), + ]); + const agent = createPracticeAgent({ chatId, chatStore }, { engine }); + await agent.prompt("Pose a synthetic problem."); + assert.equal(faux.state.callCount, 1); + assert.ok(!agent.state.messages.some((m) => m.role === "toolResult")); + const messages = agent.state.messages.map((m, i) => ({ ...m, id: `interrupted-${i}` })) as LhMessage[]; + assert.equal(activeProblemOf(messages), null); + assert.deepEqual(prose(projectView(messages)), []); + }); + } + + it("a rejected mixed-batch practice pose retries, then stops after the successful pose", async () => { + const { engine, faux } = scripted("anthropic", "claude-opus-5-5", [ + fauxAssistantMessage([ + outputCall("Try this practice problem."), + fauxToolCall("problem", { statement: "Incomplete problem.", mode: "recall" }, { id: "bad-problem" }), + ], { stopReason: "toolUse" }), + fauxAssistantMessage([ + outputCall("Here is the corrected problem.", "send-2"), + fauxToolCall("problem", { + statement: "What is two plus two?", mode: "recall", modelSolution: "Four.", + }, { id: "good-problem" }), + ], { stopReason: "toolUse" }), + fauxAssistantMessage("Premature continuation must not run."), + ]); + const agent = createPracticeAgent({ chatId, chatStore }, { engine }); + await agent.prompt("Pose a synthetic problem."); + assert.equal(faux.state.callCount, 2, "failed authoring retries; successful pose waits for the learner"); + const results = agent.state.messages.filter((m) => m.role === "toolResult" && m.toolName === "problem"); + assert.equal(results.length, 2); + assert.ok(results[0]?.role === "toolResult" && results[0].isError); + assert.ok(results[1]?.role === "toolResult" && !results[1].isError); + }); + + it("output + question streams, persists natively, replays, and reloads as prose before the question", async () => { + const lesson = "**Synthetic lesson:** four is twice two. $2 + 2 = 4$"; + const quiz = { + type: "check", nodeId: "synthetic", question: "What is two plus two?", + options: [{ label: "Four", value: "4" }, { label: "Five", value: "5" }], + correctAnswer: "4", explanation: "Add two pairs.", + }; + const { engine, faux } = scripted("openrouter", "anthropic/claude-opus-5.5", [ + fauxAssistantMessage([ + fauxThinking("Synthetic private thought."), outputCall(lesson), + fauxToolCall("quiz", quiz, { id: "quiz-1" }), + ], { stopReason: "toolUse" }), + ]); + const chat = chatStore.createChat("Synthetic native output flow"); + const user = newUserMessage("Explain synthetic arithmetic."); + chatStore.saveMessages(chat.id, [user]); + const agent = createTeacherAgent(undefined, { engine, messages: asAgentMessages([user]) }); + const handle = runPiTurn({ + agent, + updateFlushMs: 0, + onFinish: (messages, aborted) => { persistTurn(chatStore, chat.id, 1, messages, aborted); }, + }); + const wire = await new Response(handle.subscribe()).text(); + assert.ok(wire.endsWith("data: [DONE]\n\n")); + assert.equal(faux.state.callCount, 1, "question stops the mixed batch as before"); + const saved = chatStore.getChat(chat.id)!.messages; + const response = saved.find((m) => m.role === "assistant")!; + assert.equal(response.role, "assistant"); + if (response.role !== "assistant") assert.fail("missing assistant"); + assert.deepEqual(response.content.map((c) => c.type), ["thinking", "toolCall", "toolCall"]); + assert.deepEqual(response.content[1], outputCall(lesson)); + assert.ok(saved.some((m) => m.role === "toolResult" && m.toolName === SEND_TO_USER_TOOL), "native output ack persists"); + assert.ok(!saved.some((m) => m.role === "toolResult" && m.toolName === "quiz"), "question placeholder remains transient"); + const providerHistory = toLlmMessages(asAgentMessages(saved)); + assert.deepEqual(providerHistory.find((m) => m.role === "assistant")!.content, response.content, "next request keeps native call/thinking structure"); + assert.deepEqual(prose(projectView(saved)), [lesson]); + const parts = projectView(saved).flatMap((m) => m.parts); + assert.ok(parts.findIndex((p) => p.type === "text") < parts.findIndex((p) => p.type === "tool-quiz")); + const client = new SessionModel({ chatId: chat.id, initialMessages: [user], fetch: globalThis.fetch }); + await client.consumeStream(new Response(wire)); + assert.deepEqual(prose(client.getState().messages), [lesson], "native event replay shows the lesson exactly once"); + assert.deepEqual(prose(projectView(JSON.parse(JSON.stringify(saved)))), [lesson], "reload preserves the lesson"); + assert.deepEqual(teacherProseBlocks(saved), [lesson]); + assert.equal(sessionMarkdown(saved).split(lesson).length - 1, 1); + }); +}); From 90828cef49539d25d4ccac107e1f0a904ac79540 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 26 Oct 2026 02:04:18 +0200 Subject: [PATCH 24/87] fix: scope wake settlement ownership and allow legacy result deletion --- src/server/chat-handler.ts | 7 +-- src/server/chats.ts | 7 ++- src/server/jobs.ts | 23 ++++++--- tests/job-delivery-recovery.test.ts | 72 +++++++++++++++++++++++++++-- 4 files changed, 95 insertions(+), 14 deletions(-) diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index 74ed3c8..8415688 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -484,7 +484,8 @@ export async function handleChatInput( // run's first LLM call (the native replacement for continuation // coalescing). Settlement still cannot beat the flow below: it needs // a streamed response, which never happens inside the constructor. - registerLiveAgent(chatId, constructed, input.type !== "steers" && input.type !== "continue"); + registerLiveAgent(chatId, constructed, input.type !== "steers" && input.type !== "continue", + input.type === "steers" ? new Set(appends.map((message) => message.id)) : undefined); // Debug recorder run marker (owner, 2026-10-04): correlates the // JSONL event stream with the record's turns; no-op unless // LH_PROVIDER_EVENTS=1. @@ -525,7 +526,7 @@ export async function handleChatInput( // merely because usage/extraction/other settlement work failed. const persisted = stores.chatStore.getChat(chatId)?.messages ?? []; reconcileUserSteers(chatId, persisted); - settleSteers(chatId, persisted, true); + settleSteers(chatId, persisted, true, constructed); }, onFinish: (turnMessages, isAborted) => { // Wave B: the record write is the agent's own final state — @@ -577,7 +578,7 @@ export async function handleChatInput( // resolve; stranded markers return to pending), then let // the pump take any stragglers down the idle wake. unregisterLiveAgent(chatId); - settleSteers(chatId, stamped); + settleSteers(chatId, stamped, false, constructed); // Composer steers (2026-10-03): delivered ids dropped, stranded // ones stay parked for the chat's next run (the registry is the // stranding safety net — the agent's undrained copy died above). diff --git a/src/server/chats.ts b/src/server/chats.ts index 2e6f2c9..857d3d1 100644 --- a/src/server/chats.ts +++ b/src/server/chats.ts @@ -1,4 +1,5 @@ import { abortExtraction, extractionStatuses } from "../agent/subagents/extraction.ts"; +import { isJobLive } from "../agent/subagents/aborts.ts"; import { coverageOf } from "../core/coverage.ts"; import { deleteChatTraces, type ChatDeleteMode, type ChatDeletionStats } from "../core/graph-deletion.ts"; import { titleFromPrompt } from "../core/contracts.ts"; @@ -77,7 +78,11 @@ function runningWorkBlocker(chatId: string): Extract job.status === "running"); + // Legacy records kept completed workers running until delivery. A saved + // result without a live loop is pending session data, not computing work; + // deletion must not depend on a prior recovery poll to recognize it. + const runningJobs = stores.jobStore.listJobs(chatId).filter((job) => + job.status === "running" && (!job.result || isJobLive(job.id))); if (runningJobs.length > 0) { return { ok: false, diff --git a/src/server/jobs.ts b/src/server/jobs.ts index 98becef..f4b2cd2 100644 --- a/src/server/jobs.ts +++ b/src/server/jobs.ts @@ -88,6 +88,9 @@ interface QueuedSteer { promise: Promise; /** Wake cargo remains owned here, but must not steer into its own run. */ waking?: boolean; + /** The constructed vehicle, once startup claims a turn. Until then only + * the pump owns waking: an unrelated foreground settlement cannot free it. */ + wakeAgent?: Agent; /** A failed start waits for a learner run or process restart, not a timer. */ deferred?: boolean; resolve: () => void; @@ -286,10 +289,13 @@ function handOffEligible(chatId: string, agent: Agent): void { * markers are steered into the run right here. The settle seam cannot * beat the registration: settlement needs a streamed response, which * never happens inside the constructor call. */ -export function registerLiveAgent(chatId: string, agent: Agent, allowDeferred = true): void { +export function registerLiveAgent(chatId: string, agent: Agent, allowDeferred = true, wakeSteerIds?: ReadonlySet): void { state().liveAgents.set(chatId, agent); restorePendingDeliveries(chatId); - if (allowDeferred) for (const entry of state().queues.get(chatId) ?? []) entry.deferred = false; + for (const entry of state().queues.get(chatId) ?? []) { + if (allowDeferred) entry.deferred = false; + if (entry.waking && wakeSteerIds?.has(entry.steer.id)) entry.wakeAgent = agent; + } handOffEligible(chatId, agent); } @@ -369,7 +375,7 @@ export function restoreUncommittedAutomatic(chatId: string, agent: Agent, automa * This is what makes the live hand-off safe: pi owns no retry for a marker * stranded in a settled agent's queue, so the registry stays the truth and * the dead agent's copy is simply abandoned (GC'd with the agent). */ -export function settleSteers(chatId: string, persisted: { id?: string }[], failed = false): void { +export function settleSteers(chatId: string, persisted: { id?: string }[], failed = false, settledAgent?: Agent): void { const queue = state().queues.get(chatId); if (!queue || queue.length === 0) return; const deliveredIds = new Set(persisted.map((m) => m.id).filter(Boolean)); @@ -379,9 +385,11 @@ export function settleSteers(chatId: string, persisted: { id?: string }[], faile finalizeSteer(entry.steer); entry.resolve(); } else { - entry.handedTo = undefined; - entry.waking = false; - if (failed && jobIdOf(entry.steer)) entry.deferred = true; + const handedHere = entry.handedTo !== undefined && (!settledAgent || entry.handedTo === settledAgent); + const wakeHere = settledAgent !== undefined && entry.wakeAgent === settledAgent; + if (handedHere) entry.handedTo = undefined; + if (wakeHere) { entry.waking = false; entry.wakeAgent = undefined; } + if (failed && (handedHere || wakeHere) && jobIdOf(entry.steer)) entry.deferred = true; remaining.push(entry); } } @@ -577,7 +585,7 @@ async function pump(chatId: string): Promise { if (saved) noteSteersDurable(chatId, saved.messages); const owned = fire.filter((entry) => s.queues.get(chatId)?.includes(entry)); if (status === 403 || status === 409) { - for (const entry of owned) entry.waking = false; + for (const entry of owned) { entry.waking = false; entry.wakeAgent = undefined; } if (status === 403) { parked = true; return; } await delay(RETRY_BACKOFF_MS[Math.min(attempt++, RETRY_BACKOFF_MS.length - 1)]!); continue; @@ -627,6 +635,7 @@ state().stopConnectionWatch = onSubscriptionChange(() => { function deferOrReject(chatId: string, entry: QueuedSteer, error: Error): void { entry.waking = false; + entry.wakeAgent = undefined; if (jobIdOf(entry.steer)) { entry.deferred = true; console.error(`[steer ${entry.steer.id}] delivery remains pending:`, error.message); diff --git a/tests/job-delivery-recovery.test.ts b/tests/job-delivery-recovery.test.ts index 25618c7..8e34e6e 100644 --- a/tests/job-delivery-recovery.test.ts +++ b/tests/job-delivery-recovery.test.ts @@ -3,9 +3,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +import { Agent } from "@earendil-works/pi-agent-core"; import { after, afterEach, test } from "node:test"; import { createFauxCore, fauxAssistantMessage } from "@earendil-works/pi-ai"; -import type { LhMessage } from "../src/core/native-messages.ts"; +import { toLlmMessages, type LhMessage } from "../src/core/native-messages.ts"; import { buildSubagentSteer, type JobKind, type SteerMessage } from "../src/core/contracts.ts"; // Install isolation before importing any storage/configuration composition. @@ -186,16 +187,59 @@ test("failed start stays quiet across sweeps, then joins a learner run", async ( test("HTTP acceptance without a saved marker is not delivery completion or a retry trigger", async () => { const owner = chat(); const { job, steer } = pending(owner.id); let attempts = 0; - setTurnRunner(async () => { attempts++; assert(beginTurn(owner.id)); return 200; }); + const accepted = agent(); + setTurnRunner(async () => { + attempts++; assert(beginTurn(owner.id)); + registerLiveAgent(owner.id, accepted.value, false, new Set([steer.id])); + return 200; + }); const delivery = deliverJobResult(steer); await until(() => attempts === 1); await delay(30); assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending"); - unregisterLiveAgent(owner.id); endTurn(owner.id); settleSteers(owner.id, [], true); + unregisterLiveAgent(owner.id); endTurn(owner.id); settleSteers(owner.id, [], true, accepted.value); sweep(); await delay(900); assert.equal(attempts, 1); const next = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, next.value); assert.equal(next.handed.length, 1); persist(owner.id, next.handed); unregisterLiveAgent(owner.id); endTurn(owner.id); await delivery; }); +test("a racing foreground settlement cannot release an outstanding staged wake", async () => { + const owner = chat(); const { steer } = pending(owner.id); + let started = false; + let release!: () => void; + const startup = new Promise((resolve) => { release = resolve; }); + let modelCopies = 0; + let nativeCopies = 0; + setTurnRunner(async (id, input) => { + assert.equal(input.type, "steers"); if (input.type !== "steers") return 500; + started = true; + // Subscription wake startup can await catalog refresh before claiming + // the session. Its cargo is not yet a durable input prefix. + await startup; + const faux = createFauxCore({}); + faux.setResponses([(context) => { + modelCopies = JSON.stringify(context.messages).split(result.brief).length - 1; + return fauxAssistantMessage("Fixture wake response"); + }]); + const wake = new Agent({ initialState: { model: faux.getModel(), messages: input.steers }, convertToLlm: toLlmMessages, streamFn: faux.streamSimple }); + assert(beginTurn(id)); + registerLiveAgent(id, wake, false, new Set(input.steers.map((message) => message.id))); + await wake.continue(); + nativeCopies = wake.state.messages.filter((message) => (message as LhMessage).id === steer.id).length; + persist(id, wake.state.messages.filter((message) => message.role !== "system") as LhMessage[]); + unregisterLiveAgent(id); endTurn(id); settleSteers(id, stores.chatStore.getChat(id)!.messages, false, wake); + return 200; + }); + const delivery = deliverJobResult(steer); + await until(() => started); + const foreground = agent(); assert(beginTurn(owner.id)); registerLiveAgent(owner.id, foreground.value); + assert.equal(foreground.handed.length, 0, "outstanding wake owns the marker"); + unregisterLiveAgent(owner.id); endTurn(owner.id); settleSteers(owner.id, [], false, foreground.value); + release(); await delivery; + assert.equal(modelCopies, 1, "one result in the model's initial context"); + assert.equal(nativeCopies, 1, "no self-steering of staged wake cargo"); + assert.equal(stores.chatStore.getChat(owner.id)!.messages.filter((message) => message.id === steer.id).length, 1); +}); + test("canonical save wins even if delivery bookkeeping fails; no marker replay", async () => { const owner = chat(true); const { job, steer } = pending(owner.id); const delivery = deliverJobResult(steer); @@ -252,6 +296,28 @@ test("fresh process restores a pending delivery on registration without a jobs p assert.equal(stores.chatStore.getChat(owner.id)!.messages.filter((message) => message.id === steer.id).length, 1); }); +test("deleting legacy completed work needs no preceding recovery poll", async () => { + const owner = chat(true); + const old = stores.jobStore.createJob({ kind: "research", chatId: owner.id, toolCallId: "legacy-delete" }); + stores.jobStore.updateJob(old.id, { result }); + const outcome = await deleteChatSession(owner.id, "preserve-graph"); + assert(outcome.ok); + assert.equal(stores.jobStore.getJob(old.id), null); + assert.equal(stores.chatStore.getChat(owner.id), null); +}); + +test("deletion still refuses a computing worker or a live turn", async () => { + const owner = chat(); + const computing = stores.jobStore.createJob({ kind: "research", chatId: owner.id, toolCallId: "computing-delete" }); + const blocked = await deleteChatSession(owner.id, "preserve-graph"); + assert(!blocked.ok && blocked.code === "jobs_running"); + stores.jobStore.updateJob(computing.id, { status: "done" }); + assert(beginTurn(owner.id)); + const live = await deleteChatSession(owner.id, "preserve-graph"); + assert(!live.ok && live.code === "turn_in_flight"); + endTurn(owner.id); +}); + test("deleting a session discards its pending deliveries without starting a turn", async () => { const owner = chat(true); const { job, steer } = pending(owner.id); let wakes = 0; setTurnRunner(async () => { wakes++; return 500; }); From a19f53b2b45027f0b1a531d11527f8a45d38cc53 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 26 Oct 2026 02:57:33 +0200 Subject: [PATCH 25/87] test: preserve PDF history guards alongside canonical result delivery --- tests/jobs.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/jobs.test.ts b/tests/jobs.test.ts index d8b877a..be5478b 100644 --- a/tests/jobs.test.ts +++ b/tests/jobs.test.ts @@ -801,6 +801,7 @@ describe("pdf runner (sources v0)", () => { const toolCallId = `call-pdf-relative-${mode}`; const input = { ...PDF_INPUT, path: relativePath }; const messages = nativeToolMessages("read this pdf", "pdf", toolCallId, input); + const before = structuredClone(messages); chatStore.saveMessages(chat.id, messages); const job = jobStore.createJob({ kind: "pdf", chatId: chat.id, toolCallId }); trackJob(job.id); @@ -813,15 +814,31 @@ describe("pdf runner (sources v0)", () => { pdfModelOverride.current = undefined; } + // Worker completion is not the delivery barrier: wait for canonical + // acceptance before asserting the history prefix and appended result. + await waitFor(() => jobStore.getJob(job.id)?.delivery?.status === "delivered", 8000, "canonical PDF delivery acknowledgement"); const done = jobStore.getJob(job.id)!; assert.equal(done.status, "done"); + assert.deepEqual(done.delivery, { kind: "result", status: "delivered" }); const result = done.result as PdfResult; assert.equal(result.path, path.join(home, relativePath)); assert.match(result.brief, /integral rule/); assert.deepEqual(result.pagesRead, [1, 2]); assert.deepEqual(result.pagesConsulted, [1, 2]); assert.equal(delivery.steers.length, 1); - assert.deepEqual(chatStore.getChat(chat.id)!.messages, messages, "tool-call arguments remain as supplied"); + assert.equal(input.path, relativePath, "dispatch input remains relative"); + const persisted = chatStore.getChat(chat.id)!.messages; + assert.deepEqual(persisted.slice(0, before.length), before, "original history and tool-call arguments remain as supplied"); + const steer = delivery.steers[0]!; + assert.equal(steer.id, `steer_${job.id}`); + assert.equal(steer.role, "custom"); + assert.equal(steer.customType, "subagent-steer"); + const details = steer.details as { jobId: string; kind: string; toolCallId: string; result: PdfResult }; + assert.equal(details.jobId, job.id); + assert.equal(details.kind, "pdf"); + assert.equal(details.toolCallId, toolCallId); + assert.deepEqual(details.result, result, "the canonical steer carries the resolved PDF result"); + assert.deepEqual(persisted.slice(before.length), [steer], "exactly one captured result is appended after the unchanged history"); }); } From c0c670b810509a0cd265648d16a5e36141291c70 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 26 Oct 2026 03:00:40 +0200 Subject: [PATCH 26/87] feat: enable subscription connections in web and dev --- .gitignore | 3 + app/api/subscriptions/browser/route.ts | 6 + app/api/subscriptions/route.ts | 6 + components/server-events.tsx | 3 + components/settings/chatgpt-connection.tsx | 23 +-- components/settings/subscription-client.ts | 77 ++++++++++ .../settings/subscription-connections.tsx | 20 +-- components/sidebar.tsx | 3 + docs/architecture.md | 6 +- docs/development.md | 11 ++ electron/main.cjs | 9 ++ electron/preload.cjs | 4 + electron/web-subscription-browser.cjs | 19 +++ next.config.ts | 3 + scripts/build-server.mjs | 3 +- scripts/smoke-web-subscriptions.mjs | 125 ++++++++++++++++ src/agent/chatgpt-bridge.ts | 20 ++- src/agent/subscription-bridge.ts | 13 +- src/agent/subscription-runtime.ts | 30 ++++ src/core/events.ts | 3 + src/server/index.ts | 1 + src/server/web-subscription-http.ts | 68 +++++++++ src/server/web-subscriptions.ts | 127 ++++++++++++++++ tests/chatgpt-dev-ipc.test.ts | 8 +- tests/desktop-platform.test.ts | 24 ++- tests/provider-tracing.test.ts | 4 +- tests/web-subscription-client.test.tsx | 99 +++++++++++++ tests/web-subscription-payload.test.ts | 21 +++ tests/web-subscriptions.test.ts | 140 ++++++++++++++++++ 29 files changed, 838 insertions(+), 41 deletions(-) create mode 100644 app/api/subscriptions/browser/route.ts create mode 100644 app/api/subscriptions/route.ts create mode 100644 components/settings/subscription-client.ts create mode 100644 electron/web-subscription-browser.cjs create mode 100644 scripts/smoke-web-subscriptions.mjs create mode 100644 src/agent/subscription-runtime.ts create mode 100644 src/server/web-subscription-http.ts create mode 100644 src/server/web-subscriptions.ts create mode 100644 tests/web-subscription-client.test.tsx create mode 100644 tests/web-subscription-payload.test.ts create mode 100644 tests/web-subscriptions.test.ts diff --git a/.gitignore b/.gitignore index 320a543..5f72ce7 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ dist/ # dev auth/keys store (auth-keys wave): repo-root auth.json — secrets never committed auth.json +chatgpt-credentials.json* +github-copilot-credentials.json* +xai-credentials.json* # dev-fresh launcher scratch (fresh-user sim): disposable per launch .fresh-data/ diff --git a/app/api/subscriptions/browser/route.ts b/app/api/subscriptions/browser/route.ts new file mode 100644 index 0000000..760380f --- /dev/null +++ b/app/api/subscriptions/browser/route.ts @@ -0,0 +1,6 @@ +import { subscriptionBrowserResponse } from "@/src/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const GET = subscriptionBrowserResponse; +export const POST = subscriptionBrowserResponse; diff --git a/app/api/subscriptions/route.ts b/app/api/subscriptions/route.ts new file mode 100644 index 0000000..3674db5 --- /dev/null +++ b/app/api/subscriptions/route.ts @@ -0,0 +1,6 @@ +import { subscriptionControlsResponse } from "@/src/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const GET = subscriptionControlsResponse; +export const POST = subscriptionControlsResponse; diff --git a/components/server-events.tsx b/components/server-events.tsx index bb0331b..0c8cd16 100644 --- a/components/server-events.tsx +++ b/components/server-events.tsx @@ -116,6 +116,9 @@ export function wireSource(source: NotifySource, hub: ReturnType | null>(null); +/** Transport adapters may also be mounted in isolation with an injected bridge. */ +export function useNotifyHub() { return useContext(EventsContext); } + export function ServerEventsProvider({ children, connect }: { children: ReactNode; connect?: () => NotifySource }) { const hub = useMemo(() => createNotifyHub(), []); useEffect(() => wireSource((connect ?? openEventsChannel)(), hub), [hub, connect]); diff --git a/components/settings/chatgpt-connection.tsx b/components/settings/chatgpt-connection.tsx index 3e368dd..f9d2d69 100644 --- a/components/settings/chatgpt-connection.tsx +++ b/components/settings/chatgpt-connection.tsx @@ -5,8 +5,10 @@ import type { ChatGPTStatus } from "@/src/core"; import { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls"; import { Button } from "@/components/ui/button"; import { Toggle } from "./controls"; +import { useChatGPTBridge } from "./subscription-client"; export const CHATGPT_NOTICE = "LearnHarness stores your ChatGPT account details and credentials in an unencrypted local file on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness."; +export function chatGPTNotice(web = false): string { return web ? `${CHATGPT_NOTICE.replace("on this device", "on the machine running LearnHarness")} Complete ChatGPT sign-in in a browser on that same machine.` : CHATGPT_NOTICE; } export const CHATGPT_BACKGROUND_HINT = "Background work counts toward your ChatGPT plan usage."; const BACKGROUND_LABEL = "Allow background use for all ChatGPT accounts"; const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed."; @@ -41,6 +43,8 @@ const Context = createContext({ /** UI lifetime spans setup's keys → models transition. Only existing safe controls * are used; account IDs remain control values, never displayed account labels. */ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { + const bridge = useChatGPTBridge(); + const web = typeof window !== "undefined" && bridge !== window.lh?.chatgpt; const [status, setStatus] = useState(empty); const [dialog, setDialog] = useState(null); const [busy, setBusy] = useState(false); @@ -79,14 +83,14 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { if (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background) { operation.current = "change"; const request = ++statusSequence.current; - const result: unknown = await window.lh!.chatgpt!.consent(attempt.background); + const result: unknown = await bridge!.consent(attempt.background); if (!owns(attempt)) return; if (!isStatus(result)) { setError(replyError(result)); finish(attempt); return; } replyFailure = result.error; let saved: ChatGPTStatus | null; if (request !== statusSequence.current) { const refresh = ++statusSequence.current; - const value: unknown = await window.lh!.chatgpt!.status(); + const value: unknown = await bridge!.status(); if (!owns(attempt)) return; saved = refresh === statusSequence.current ? publishStatus(value) : statusRef.current; } else saved = publishStatus(result); @@ -120,7 +124,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { }; useEffect(() => { mounted.current = true; - const bridge = window.lh?.chatgpt; const refresh = async () => { const request = ++statusSequence.current; try { @@ -131,7 +134,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { void refresh(); const off = bridge?.onChange(() => void refresh()); return () => { mounted.current = false; generation.current++; loginAttempt.current = null; off?.(); }; - }, []); + }, [bridge]); const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId) ?? dialog.account : undefined; const managedPresent = !!managed && status.accounts.some((a) => a.id === managed.id); @@ -161,13 +164,13 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { // Keep cancellation serialized even after login's pending IPC reply. operation.current = "cancel"; setBusy(true); const request = ++statusSequence.current; - void window.lh?.chatgpt?.cancel().then(async (value: unknown) => { + void bridge?.cancel().then(async (value: unknown) => { if (!mounted.current || generation.current !== intent) return; if (!isStatus(value)) { setError(replyError(value)); return; } let next: ChatGPTStatus | null; if (request !== statusSequence.current) { const refresh = ++statusSequence.current; - const current: unknown = await window.lh!.chatgpt!.status(); + const current: unknown = await bridge!.status(); if (!mounted.current || generation.current !== intent) return; next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current; } else next = publishStatus(value); @@ -195,7 +198,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { let next: ChatGPTStatus | null; if (request !== statusSequence.current) { const refresh = ++statusSequence.current; - const current: unknown = await window.lh!.chatgpt!.status(); + const current: unknown = await bridge!.status(); if (!mounted.current || intent !== generation.current) return; next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current; } else next = publishStatus(value); @@ -214,7 +217,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { finally { if (intent === generation.current) { operation.current = null; if (mounted.current) setBusy(false); } } } async function login() { - const bridge = window.lh?.chatgpt; const owner = dialogRef.current; if (!bridge || operation.current || !status.available || status.pending || owner?.kind !== "connect") return; const attempt: LoginAttempt = { generation: ++generation.current, dialog: owner, registration, background, backgroundDirty, pending: false, phase: "starting" }; @@ -241,7 +243,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { // No finally: pending OAuth outlives this IPC promise. Only its status // completion, failure, or explicit cancellation releases the attempt. } - const bridge = typeof window !== "undefined" ? window.lh?.chatgpt : undefined; const context = { status, busy, error: warning ? null : visibleError, inlineError: dialog || warning ? null : visibleError, warning: dialog?.kind === "warning" ? null : warning, connect: openConnect, @@ -262,7 +263,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { : dialog.kind === "manage" && dialog.confirm ?

{CHATGPT_DISCONNECT_COPY.title}

{managed?.email || "ChatGPT account"}

-

{CHATGPT_DISCONNECT_COPY.body}

+

{web ? CHATGPT_DISCONNECT_COPY.body.replace("from this device", "from the machine running LearnHarness") : CHATGPT_DISCONNECT_COPY.body}

{context.error &&

{context.error}

}
@@ -277,7 +278,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { {registration && }
} -

{CHATGPT_NOTICE}

+

{chatGPTNotice(web)}

: null}
button:disabled]:cursor-auto" : ""}`} data-testid="chatgpt-global-background"> { diff --git a/components/settings/subscription-client.ts b/components/settings/subscription-client.ts new file mode 100644 index 0000000..a580a08 --- /dev/null +++ b/components/settings/subscription-client.ts @@ -0,0 +1,77 @@ +"use client"; + +import { useMemo } from "react"; +import type { ChatGPTControlBridge, ChatGPTStatus, NativeSubscriptionControlBridge, NativeSubscriptionProviderId, NativeSubscriptionStatus } from "@/src/core"; +import { useNotifyHub, type createNotifyHub } from "@/components/server-events"; + +type Hub = ReturnType | null; +type Reply = { value: T; browserAction?: string; error?: string }; +const PATH = "/api/subscriptions"; +const headers = { "Content-Type": "application/json", "x-learnharness-subscriptions": "1" }; + +async function status() { + const response = await fetch(PATH, { headers, cache: "no-store" }); + if (!response.ok) throw new Error("Subscription status is unavailable."); + return response.json() as Promise<{ chatgpt: ChatGPTStatus; native: NativeSubscriptionStatus[] }>; +} +async function control(providerId: string, op: string, fields: Record = {}): Promise> { + const response = await fetch(PATH, { method: "POST", headers, body: JSON.stringify({ providerId, op, ...fields }) }); + if (!response.ok) throw new Error("The subscription connection could not be changed."); + return response.json() as Promise>; +} +function onChange(hub: Hub, listener: () => void): () => void { + const offEvent = hub?.subscribe("subscriptions-changed", listener); + const offResync = hub?.onResync(listener); + return () => { offEvent?.(); offResync?.(); }; +} +/** Reserve the new tab synchronously in the click gesture. Only a one-use + * same-origin action URL enters app JS; provider URLs remain in the owner. + * Electron dev has no tabs: its narrow main-process opener performs this step. */ +async function withBrowser(action: () => Promise>): Promise { + const external = window.lh?.subscriptionTransport === "web" ? window.lh.openSubscriptionBrowser : undefined; + const tab = external ? null : window.open("about:blank", "_blank"); + if (!external && !tab) throw new Error("Allow a sign-in tab to open, then try again."); + if (tab) tab.opener = null; + try { + const reply = await action(); + if (reply.browserAction) { + if (!/^\/api\/subscriptions\/browser\?id=[0-9a-f-]{36}$/.test(reply.browserAction)) throw new Error("Invalid sign-in action."); + if (external) await external(reply.browserAction); + else if (tab && !tab.closed) tab.location.href = reply.browserAction; + else throw new Error("The sign-in tab was closed. Cancel and try again."); + } else tab?.close(); + return reply.value; + } catch (error) { tab?.close(); throw error; } +} +export function createWebChatGPTBridge(hub: Hub): ChatGPTControlBridge { + const call = async (op: string, fields?: Record) => (await control("chatgpt", op, fields)).value; + return { + status: async () => (await status()).chatgpt, + login: (id) => withBrowser(() => control("chatgpt", "login", { id })), + cancel: () => call("cancel"), select: (id) => call("select", { id }), + disconnect: (id) => call("disconnect", { id }), logout: (id) => call("logout", { id }), + consent: (enabled) => call("consent", { enabled }), + onChange: (listener) => onChange(hub, listener), + }; +} +export function createWebNativeBridge(hub: Hub): NativeSubscriptionControlBridge { + const call = async (providerId: NativeSubscriptionProviderId, op: string, fields?: Record) => { await control(providerId, op, fields); }; + return { + status: async () => (await status()).native, + login: (providerId, enabled) => call(providerId, "login", { enabled }), + cancel: (providerId) => call(providerId, "cancel"), + respond: (providerId, attemptId, promptId, value) => call(providerId, "respond", { attemptId, promptId, value }), + open: async (providerId, attemptId, actionId) => { await withBrowser(() => control(providerId, "open", { attemptId, actionId })); }, + retry: (providerId) => call(providerId, "retry"), disconnect: (providerId) => call(providerId, "disconnect"), + consent: (providerId, enabled) => call(providerId, "consent", { enabled }), + onChange: (listener) => onChange(hub, listener), + }; +} +export function useChatGPTBridge(): ChatGPTControlBridge | undefined { + const hub = useNotifyHub(); + return useMemo(() => typeof window === "undefined" ? undefined : window.lh?.subscriptionTransport !== "web" && window.lh?.chatgpt ? window.lh.chatgpt : createWebChatGPTBridge(hub), [hub]); +} +export function useNativeSubscriptionBridge(): NativeSubscriptionControlBridge | undefined { + const hub = useNotifyHub(); + return useMemo(() => typeof window === "undefined" ? undefined : window.lh?.subscriptionTransport !== "web" && window.lh?.subscriptions ? window.lh.subscriptions : createWebNativeBridge(hub), [hub]); +} diff --git a/components/settings/subscription-connections.tsx b/components/settings/subscription-connections.tsx index 0120200..6c095d1 100644 --- a/components/settings/subscription-connections.tsx +++ b/components/settings/subscription-connections.tsx @@ -6,16 +6,19 @@ import { notifySettingsSaved } from "@/components/settings-signal"; import { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls"; import { ChatGPTConnection } from "./chatgpt-connection"; import { Toggle } from "./controls"; +import { useNativeSubscriptionBridge } from "./subscription-client"; export const COPILOT_POLICY_NOTICE = "Pi's native Copilot sign-in may enable additional models whose policy is not yet configured. Models explicitly disabled by policy are not enabled. Organization restrictions still apply."; export const SUBSCRIPTION_BACKGROUND_HINT = "Allow LearnHarness helper agents and automatic follow-ups to use this connection."; -export function subscriptionNotice(label: string): string { return `Your prompts, tool results, and relevant session context are sent to ${label} to run the work you select. Credentials are stored locally in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key.`; } -export function subscriptionDisconnectCopy(label: string): string { return `Disconnect ${label} from LearnHarness? This stops work using this connection and removes its saved credentials from this device. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them.`; } +export function subscriptionNotice(label: string, web = false): string { return `Your prompts, tool results, and relevant session context are sent to ${label} to run the work you select. Credentials are stored ${web ? "on the machine running LearnHarness" : "locally"} in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key.`; } +export function subscriptionDisconnectCopy(label: string, web = false): string { return `Disconnect ${label} from LearnHarness? This stops work using this connection and removes its saved credentials from ${web ? "the machine running LearnHarness" : "this device"}. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them.`; } export function emptySubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus { return { providerId, available: false, connected: false, pending: false, allowBackgroundUse: true, connectionId: null, epoch: 0, authorityRevision: 0, revision: 0 }; } const Context = createContext({ statuses: [] as NativeSubscriptionStatus[], busy: false, open: (_providerId: NativeSubscriptionProviderId) => {} }); const CONTROL_ERROR = "The connection could not be changed. Try again."; export function NativeSubscriptionUIProvider({ children }: { children: React.ReactNode }) { + const bridge = useNativeSubscriptionBridge(); + const web = typeof window !== "undefined" && bridge !== window.lh?.subscriptions; const [statuses, setStatuses] = useState([]); const [dialog, setDialog] = useState(null); const [view, setView] = useState<"notice" | "connection" | "manage">("notice"); @@ -30,7 +33,7 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea async function refresh() { const request = ++sequence.current; try { - const values = await window.lh?.subscriptions?.status(); + const values = await bridge?.status(); if (!mounted.current || request !== sequence.current || !Array.isArray(values)) return; const accepted = NATIVE_SUBSCRIPTIONS.map(({ providerId }) => { const next = values.find((s) => s.providerId === providerId), old = statusesRef.current.find((s) => s.providerId === providerId); @@ -41,10 +44,10 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea } useEffect(() => { mounted.current = true; void refresh(); - const off = window.lh?.subscriptions?.onChange(() => { void refresh(); notifySettingsSaved("keys"); }); + const off = bridge?.onChange(() => { void refresh(); notifySettingsSaved("keys"); }); return () => { mounted.current = false; generation.current++; off?.(); }; // The root-lifetime subscriber reads current state through refs. - }, []); + }, [bridge]); useEffect(() => { setPromptValue(""); }, [prompt?.id]); function open(providerId: NativeSubscriptionProviderId) { if (operation || statusesRef.current.some((s) => s.pending)) return; @@ -62,7 +65,6 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea function close() { if (!dialog || busy) return; if (confirm) { setConfirm(false); setError(null); return; } - const bridge = window.lh?.subscriptions; if ((status?.pending || operation === "login") && bridge) { void control(() => bridge.cancel(dialog), true); return; } generation.current++; setDialog(null); setError(null); } @@ -71,23 +73,21 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea generation.current++; setError(null); setConfirm(false); setBackground(status.allowBackgroundUse); setBackgroundDirty(false); setView("notice"); } async function login() { - const bridge = window.lh?.subscriptions; if (!dialog || !bridge || operation || !status?.available) return; const owner = ++generation.current; setOperation("login"); setError(null); try { await bridge.login(dialog, backgroundDirty ? background : undefined); await refresh(); if (mounted.current && generation.current === owner) setDialog(null); } catch { if (mounted.current && generation.current === owner) setError("Sign-in did not complete. Try connecting again."); } finally { if (mounted.current && generation.current === owner) setOperation(null); } } - const bridge = typeof window === "undefined" ? undefined : window.lh?.subscriptions; return {children} {dialog && descriptor && status &&

{descriptor.label}

{confirm ? <>

Disconnect {descriptor.label}?

-

{subscriptionDisconnectCopy(descriptor.label)}

+

{subscriptionDisconnectCopy(descriptor.label, web)}

: view === "notice" ? <> -

{subscriptionNotice(descriptor.label)}

+

{subscriptionNotice(descriptor.label, web)}

{dialog === "github-copilot" &&

{COPILOT_POLICY_NOTICE}

} : <>
diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 6ca9c21..508494a 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -61,6 +61,9 @@ declare global { lh?: { /** Read-only host OS; determines the native-control gutter. */ platform?: string; + /** External Next desktop-dev uses the server's web connections. */ + subscriptionTransport?: "desktop" | "web"; + openSubscriptionBrowser?: (action: string) => Promise; chatgpt?: ChatGPTControlBridge; subscriptions?: NativeSubscriptionControlBridge; expandWindow: () => void; diff --git a/docs/architecture.md b/docs/architecture.md index ed7d869..f982887 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -425,9 +425,11 @@ The Electron main is a launcher + credential/security guard, never a host for te ### 8.1 Subscription connection boundary -`electron/chatgpt.cjs` retains the existing verified-account ChatGPT authentication, storage and revocation behavior. `electron/subscriptions.cjs` delegates GitHub Copilot/Grok login and refresh to the installed Pi SDK through its public provider/Models interfaces. Their single saved connections live independently in owner-only, atomically written `github-copilot-credentials.json` / `xai-credentials.json` beside the existing app credential files. Electron main alone owns durable OAuth credentials and serialized refresh; no global Pi files or API-key credential precedence are involved. +`electron/chatgpt.cjs` retains the existing verified-account ChatGPT authentication, storage and revocation behavior. `electron/subscriptions.cjs` delegates GitHub Copilot/Grok login and refresh to the installed Pi SDK through its public provider/Models interfaces. Their single saved connections live independently in owner-only, atomically written `github-copilot-credentials.json` / `xai-credentials.json` beside the existing app credential files. In packaged desktop mode Electron main alone owns durable OAuth credentials and serialized refresh; no global Pi files or API-key credential precedence are involved. -The renderer gets projected status, public device user codes, prompts and controls over origin-pinned IPC. The privately launched server child receives credential-free catalogs and scoped request auth (including native headers/account endpoints), never refresh tokens. `src/agent/subscription-bridge.ts` rejects framework-only IPC and invalidates stale connection/catalog scopes. `subscription-stream.ts` dispatches directly through native providers, retaining native protocol identity on the wire and restoring logical app identity in assistant history. Persisted routes `chatgpt`, `github-copilot-subscription` and `xai-subscription` are distinct from ordinary API routes and never fall back to them. +The packaged desktop renderer gets projected status, public device user codes, prompts and controls over origin-pinned IPC. The privately launched server child receives credential-free catalogs and scoped request auth (including native headers/account endpoints), never refresh tokens. `src/agent/subscription-bridge.ts` rejects framework-only IPC and invalidates stale connection/catalog scopes. `subscription-stream.ts` dispatches directly through native providers, retaining native protocol identity on the wire and restoring logical app identity in assistant history. Persisted routes `chatgpt`, `github-copilot-subscription` and `xai-subscription` are distinct from ordinary API routes and never fall back to them. + +For ordinary Next web/dev, `src/server/web-subscriptions.ts` composes these same plain-Node controllers into one lazy process-global credential owner beside configured `AUTH_FILE`. The server injects the private transport through `src/agent/subscription-runtime.ts`; the agent layer never imports the server layer. Existing scoped leases, authority revisions and foreground/background abort listeners work through that transport, without exposing an HTTP token/lease operation. `src/server/web-subscription-http.ts` exposes only same-origin status/control operations. Generic `subscriptions-changed` notifications use the existing SSE notify→fetch channel; opaque one-use browser actions redirect to provider pages without putting authorization URLs in app JSON. Browser adapters preserve the UI bridge contracts; Electron dev uses the web owner with a narrow system-browser opener. Desktop markers fail closed rather than falling back to web credentials. This is the existing shared developer-server trust model, not user authentication or DNS-rebinding protection; LAN behavior is unchanged. Only one server process may own an auth directory. See [development](development.md#subscription-connections-in-webdev) for storage, browser locality and access limitations. `subscription-request-cycle.ts` is the shared admission/automatic-input recovery state machine; the old ChatGPT module is its compatibility export. Background consent is independent per provider, default ON, with foreground-preserving cancellation and queue ownership retained on no-dispatch. Native usage rejected before dispatch is transiently excluded from helper usage collectors, without adding persisted provenance fields. Catalogs and read-time pricing preserve the selected connection: subscription use displays compact provider labels, API dollars remain dollars, and mixed usage joins both without estimating subscription dollars or claiming detected tiers/allowances. diff --git a/docs/development.md b/docs/development.md index 062a72e..1859053 100644 --- a/docs/development.md +++ b/docs/development.md @@ -22,6 +22,7 @@ npm start # serve the production build npm test # the full suite (node:test via tsx; deterministic, no LLM, no network) npm run lint # eslint — includes the import law (architecture §2) npm run typecheck # tsc --noEmit +node scripts/smoke-web-subscriptions.mjs # after build: offline Next start/dev + reload gate npm run desktop:dev # next dev + Electron shell pointed at it npm run desktop:dist # local signed/notarized macOS arm64 build (LH_NOTARY keychain) @@ -47,6 +48,16 @@ Secrets and machine-specific paths only. Model choice and thinking budgets are t The packaged desktop app takes keys from `userData/auth.json` (BYOK, mode 0600, read/written directly by the server via `LH_AUTH_FILE`). PDFs render natively with pdfjs/Skia; SVGs use resvg. +### Subscription connections in web/dev + +Ordinary `npm run dev` and `npm start` support the existing **ChatGPT, GitHub Copilot and Grok** connections in Settings → Providers. No separate launcher or change to Next's listening/LAN behavior is required. `npm run desktop:dev` uses these same dev-server connections; the packaged desktop app keeps its separate main-process credentials and private IPC transport. + +Web/dev saves `chatgpt-credentials.json`, `github-copilot-credentials.json` and `xai-credentials.json` **beside the configured API-key `auth.json`**: the project directory by default, or the directory containing `LH_AUTH_FILE`. These are owner-only plaintext files, not files under `data/`, desktop Application Support, or a global Pi profile. Git and standalone-build exclusions cover both the files and atomic-write temporary files. Run only one server process per credential directory: refresh serialization is process-local, not a cross-process lock. + +Sign-in opens a new tab in the browser that initiated it; permit that tab if your browser blocks popups. **ChatGPT sign-in must be completed in a browser on the server's machine**, because its callback listener remains `127.0.0.1` on that machine. Copilot/Grok use device codes. In the Electron dev shell, provider pages open in the system browser instead. Once connected, credentials and refresh remain server-owned; other browsers using that server share its connections and background-use consent, just as they share its API keys. Disconnect, storage-failure handling, and provider-specific background consent retain the existing behavior; subscription routes never fall back to API-key billing. + +This remains a **trusted developer/shared-server mode**, not multi-user hosting. Subscription controls require same-origin JSON/custom-header requests; browser-opening actions are short-lived, origin-bound and one-use. These checks prevent ordinary cross-origin browser controls, **not user authentication or DNS-rebinding protection**. Do not expose an unprotected dev/server instance to untrusted users or the public Internet; a public deployment needs an independent access/host gate. Remote ChatGPT callback routing, per-user accounts and credential import are not implemented. + ## Architecture Five layers, dependency arrows pointing inward only, machine-enforced as four ESLint zones (shell → barrels only; core and agent never import below themselves). Two composition roots, one writer per store. The full spec — layers, systems, runtime flows, invariants, seams: [`docs/architecture.md`](docs/architecture.md). diff --git a/electron/main.cjs b/electron/main.cjs index f2c2d7d..a853736 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -30,6 +30,7 @@ const { attachChatGPTChild, trustedSender, chatGPTControl } = require("./chatgpt const { createSubscriptions, loadSubscriptionSDK } = require("./subscriptions.cjs"); const { attachSubscriptionChild, subscriptionControl } = require("./subscription-ipc.cjs"); const { desktopAPIHeaders } = require("./desktop-api.cjs"); +const { openWebSubscriptionBrowser } = require("./web-subscription-browser.cjs"); const { randomBytes } = require("node:crypto"); const { spawn } = require("node:child_process"); const fs = require("node:fs"); @@ -315,6 +316,7 @@ function createWindow(url) { contextIsolation: true, sandbox: true, nodeIntegration: false, + ...(process.env.LH_DEV_URL ? { additionalArguments: ["--lh-web-subscriptions"] } : {}), preload: path.join(__dirname, "preload.cjs"), }, // Live-resize + first-paint flash (owner, 2026-09-17): during a resize @@ -700,6 +702,13 @@ async function main() { return subscriptionControl(command, subscriptions); }); + // The dev shell has no browser tabs. Only a one-use action on its own + // Next server may be resolved here; never accept an arbitrary external URL. + ipcMain.handle("lh:subscriptions:web-browser", async (event, action) => { + if (!devUrl || !trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted development request."); + await openWebSubscriptionBrowser(action, new URL(url).origin, { openExternal: (target) => shell.openExternal(target) }); + }); + installMenu(); // Auto-update (Wave C): wire the updater before the window so the diff --git a/electron/preload.cjs b/electron/preload.cjs index 9294291..a07fe74 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -40,6 +40,10 @@ ipcRenderer.on("lh:open-settings", (_event, section) => { contextBridge.exposeInMainWorld("lh", { platform: process.platform, + ...(process.argv?.includes("--lh-web-subscriptions") ? { + subscriptionTransport: "web", + openSubscriptionBrowser: (action) => ipcRenderer.invoke("lh:subscriptions:web-browser", action), + } : {}), chatgpt: { status: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "status" }), login: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "login", id }), diff --git a/electron/web-subscription-browser.cjs b/electron/web-subscription-browser.cjs new file mode 100644 index 0000000..b61c73b --- /dev/null +++ b/electron/web-subscription-browser.cjs @@ -0,0 +1,19 @@ +"use strict"; + +/** Desktop-dev has no tabs. Resolve only an opaque action on its own Next + * server, then open the owner-validated provider page outside the renderer. + * The caller must authenticate its trusted app main frame first. */ +async function openWebSubscriptionBrowser(action, origin, { fetch: request = globalThis.fetch, openExternal }) { + if (typeof action !== "string" || !/^\/api\/subscriptions\/browser\?id=[0-9a-f-]{36}$/.test(action)) throw new Error("Invalid sign-in action."); + const response = await request(new URL(action, origin), { + method: "POST", redirect: "manual", credentials: "omit", + headers: { origin, "x-learnharness-subscriptions": "1" }, + signal: AbortSignal.timeout(15000), + }); + const target = response.headers.get("location"); + if (response.status !== 303 || !target) throw new Error("Sign-in action expired."); + const parsed = new URL(target); + if (parsed.protocol !== "https:" || parsed.username || parsed.password) throw new Error("Invalid provider page."); + await openExternal(target); +} +module.exports = { openWebSubscriptionBrowser }; diff --git a/next.config.ts b/next.config.ts index 94193d8..a4236ad 100644 --- a/next.config.ts +++ b/next.config.ts @@ -48,6 +48,9 @@ const nextConfig: NextConfig = { "*": [ "**/data/**/*", "auth.json", + "chatgpt-credentials.json*", + "github-copilot-credentials.json*", + "xai-credentials.json*", ".env*", ], }, diff --git a/scripts/build-server.mjs b/scripts/build-server.mjs index bbb1480..266ff75 100644 --- a/scripts/build-server.mjs +++ b/scripts/build-server.mjs @@ -52,7 +52,8 @@ if (exists(leakedData)) { // (the DEV BYOK key store, 0600) byte-identical into the DMG (caught in // the 2026-10-08 payload audit; excluded in next.config.ts — this guard // makes a regression loud instead of silent). -for (const secret of ["auth.json", ".env", ".env.local"]) { +const subscriptionSecrets = fs.readdirSync(STANDALONE).filter((name) => /^(chatgpt|github-copilot|xai)-credentials\.json/.test(name)); +for (const secret of ["auth.json", ...subscriptionSecrets, ".env", ".env.local"]) { if (exists(path.join(STANDALONE, secret))) { console.error(`[build-server] REFUSING: standalone contains ${secret} — dev secrets would ship in the app.`); console.error(" Check outputFileTracingExcludes in next.config.ts."); diff --git a/scripts/smoke-web-subscriptions.mjs b/scripts/smoke-web-subscriptions.mjs new file mode 100644 index 0000000..9918b65 --- /dev/null +++ b/scripts/smoke-web-subscriptions.mjs @@ -0,0 +1,125 @@ +/** Offline Next runtime gate (run after next build, with no dev server running). + * Proves cold-start registration, cross-route owner/events and dev reloads using + * synthetic credentials in a fresh profile. No OAuth, provider traffic or real + * auth/data. The dev reload changes only a source mtime, restoring it afterward. + * This is a test, not an alternative app launcher. */ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { pathToFileURL } from "node:url"; +import { createRequire } from "node:module"; +import { spawn } from "node:child_process"; +import { createServer } from "node:net"; + +const ROOT = path.resolve(import.meta.dirname, ".."); +const require = createRequire(import.meta.url); +const { SCOPES } = require("../electron/chatgpt.cjs"); +const sdk = (file) => import(pathToFileURL(path.join(ROOT, "node_modules/@earendil-works/pi-ai/dist", file))); +const { createModels } = await sdk("index.js"); +const { builtinProviders } = await sdk("providers/all.js"); +const { githubCopilotProvider } = await sdk("providers/github-copilot.js"); +const models = createModels(); +for (const provider of builtinProviders()) models.setProvider(provider); +const model = models.getModels("openai").find((entry) => entry.api === "openai-responses"); +const copilot = githubCopilotProvider().getModels()[0]; +assert(model && copilot, "Installed SDK catalogs required."); + +const profile = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-runtime-")); +await fs.chmod(profile, 0o700); +for (const name of ["home", "config", "cache", "xdg-data", "data", "pi", "claude", "codex", "tmp"]) await fs.mkdir(path.join(profile, name), { mode: 0o700 }); +await fs.writeFile(path.join(profile, "auth.json"), "{}\n", { mode: 0o600 }); +const preload = path.join(profile, "offline-fetch.mjs"); +await fs.writeFile(preload, `const original = globalThis.fetch; +globalThis.fetch = async (input, init) => { + const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url); + if (url.href === 'https://api.openai.com/v1/models') return Response.json({ models: [{ slug: ${JSON.stringify(model.id)}, display_name: 'Offline fixture ChatGPT', visibility: 'list' }] }); + if (['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) return original(input, init); + throw new Error('Offline subscription gate forbids external network: ' + url.origin + url.pathname); +};\n`, { mode: 0o600 }); +// Intentionally an allowlist, not a copy of the calling shell's environment. +const env = { + PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: path.join(profile, "home"), + XDG_CONFIG_HOME: path.join(profile, "config"), XDG_CACHE_HOME: path.join(profile, "cache"), XDG_DATA_HOME: path.join(profile, "xdg-data"), + PI_CODING_AGENT_DIR: path.join(profile, "pi"), PI_AGENT_DIR: path.join(profile, "pi"), CLAUDE_CONFIG_DIR: path.join(profile, "claude"), CODEX_HOME: path.join(profile, "codex"), + TMPDIR: path.join(profile, "tmp"), LH_AUTH_FILE: path.join(profile, "auth.json"), LH_DATA_DIR: path.join(profile, "data"), + NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`, NEXT_TELEMETRY_DISABLED: "1", +}; +const saved = { + version: 1, hostId: "offline-host", allowBackgroundUse: true, activeId: "offline-account", + registrations: [{ id: "offline-account", clientId: "oaiapp_fixture", issuer: "https://auth.openai.com", subject: "offline-subject", email: "fixture@example.invalid", tokens: { accessToken: "fixture-chatgpt-access", refreshToken: "fixture-chatgpt-refresh", idToken: "fixture-id-token", scopes: SCOPES, expiresAt: Date.now() + 3600000 } }], +}; +function timeout(promise, milliseconds, message) { + let timer; + return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), milliseconds); })]).finally(() => clearTimeout(timer)); +} +async function freePort() { + const socket = createServer(); + await new Promise((resolve, reject) => { socket.once("error", reject); socket.listen(0, "127.0.0.1", resolve); }); + const { port } = socket.address(); await new Promise((resolve) => socket.close(resolve)); return port; +} +async function run(mode) { + await fs.writeFile(path.join(profile, "chatgpt-credentials.json"), JSON.stringify(saved), { mode: 0o600 }); + for (const id of ["github-copilot", "xai"]) await fs.writeFile(path.join(profile, `${id}-credentials.json`), JSON.stringify({ + version: 1, allowBackgroundUse: true, connectionId: "00000000-0000-4000-8000-000000000001", + credential: { type: "oauth", access: id === "xai" ? "fixture-xai-access" : "tid=fixture;proxy-ep=proxy.individual.githubcopilot.com;", refresh: "fixture-refresh", expires: Date.now() + 3600000, ...(id === "github-copilot" ? { availableModelIds: [copilot.id] } : {}) }, + }), { mode: 0o600 }); + const port = await freePort(), origin = `http://localhost:${port}`; + let logs = "", exited = false; + const child = spawn(process.execPath, [path.join(ROOT, "node_modules/next/dist/bin/next"), mode, "-p", String(port)], { cwd: ROOT, env, stdio: ["ignore", "pipe", "pipe"] }); + const exit = new Promise((resolve) => child.once("exit", () => { exited = true; resolve(); })); + try { + await timeout(new Promise((resolve, reject) => { + const receive = (data) => { logs = (logs + data).slice(-16000); if (logs.includes("Ready in")) resolve(); }; + child.stdout.on("data", receive); child.stderr.on("data", receive); + child.once("error", reject); child.once("exit", (code) => reject(new Error(`Next ${mode} exited ${code}.`))); + }), 60000, `Next ${mode} readiness timeout.`); + const request = (url, command) => fetch(origin + url, { + ...(command ? { method: "POST", body: JSON.stringify(command) } : {}), + headers: { origin, "sec-fetch-site": "same-origin", "x-learnharness-subscriptions": "1", "content-type": "application/json" }, + signal: AbortSignal.timeout(60000), + }); + // Controls have not been imported through an HTTP request yet. + const catalog = (await (await request("/api/models")).json()).providers; + for (const id of ["chatgpt", "github-copilot-subscription", "xai-subscription"]) assert(catalog.find((provider) => provider.id === id)?.keyPresent, `${mode}: cold ${id} unavailable.`); + let state = await (await request("/api/subscriptions")).json(); + assert.equal(state.chatgpt.accounts[0].email, "fixture@example.invalid"); assert.equal(state.native.length, 2); + for (const secret of ["fixture-chatgpt-access", "fixture-chatgpt-refresh", "fixture-id-token", "fixture-xai-access", "fixture-refresh"]) assert(!JSON.stringify(state).includes(secret)); + const stream = await fetch(origin + "/api/events", { signal: AbortSignal.timeout(60000) }); + const reader = stream.body.getReader(); + try { + const first = await reader.read(); assert(first.value); + assert.equal((await request("/api/subscriptions", { providerId: "chatgpt", op: "consent", enabled: false })).status, 200); + let events = new TextDecoder().decode(first.value); + while (!events.includes("subscriptions-changed")) { + const frame = await timeout(reader.read(), 5000, "Cross-route subscription SSE timeout."); + assert(!frame.done, "Subscription event stream ended early."); events += new TextDecoder().decode(frame.value); + } + } finally { await reader.cancel(); } + state = await (await request("/api/subscriptions")).json(); assert.equal(state.chatgpt.allowBackgroundUse, false); + const login = await (await request("/api/subscriptions", { providerId: "chatgpt", op: "login" })).json(); + assert(login.browserAction); assert(!JSON.stringify(login).includes("auth.openai.com")); + if (mode === "dev") { + const file = path.join(ROOT, "src/server/web-subscriptions.ts"), times = await fs.stat(file); + await fs.utimes(file, new Date(), new Date()); + try { assert.equal((await request("/api/models")).status, 200); assert.equal((await request("/api/subscriptions")).status, 200); } + finally { await fs.utimes(file, times.atime, times.mtime); } + } + const navigate = () => fetch(origin + login.browserAction, { redirect: "manual", headers: { "sec-fetch-site": "same-origin" }, signal: AbortSignal.timeout(30000) }); + const browser = await navigate(); assert.equal(browser.status, 303, `${mode}: owner/action lost across routes or reload.`); + const location = new URL(browser.headers.get("location")); assert.equal(location.origin, "https://auth.openai.com"); assert.equal(new URL(location.searchParams.get("redirect_uri")).hostname, "127.0.0.1"); + assert.equal((await navigate()).status, 410); + assert.equal((await request("/api/subscriptions", { providerId: "chatgpt", op: "cancel" })).status, 200); + assert.equal((await fetch(origin + "/api/subscriptions", { method: "POST", headers: { origin: "https://unrelated.invalid", "content-type": "application/json", "x-learnharness-subscriptions": "1" }, body: JSON.stringify({ providerId: "chatgpt", op: "disconnect", id: "offline-account" }) })).status, 403); + assert.equal((await request("/api/subscriptions", { providerId: "chatgpt", op: "disconnect", id: "offline-account" })).status, 200); + assert.equal((await (await request("/api/models")).json()).providers.find((provider) => provider.id === "chatgpt").keyPresent, false); + console.log(`[smoke-web-subscriptions] ${mode}: cold catalogs, safe status, cross-route SSE/consent/disconnect, opaque redirect/replay, CSRF${mode === "dev" ? ", owner retained across source reload" : ""} passed`); + } catch (error) { console.error(logs); throw error; } + finally { + if (!exited) child.kill("SIGTERM"); + try { await timeout(exit, 5000, "Next shutdown timeout."); } + catch { child.kill("SIGKILL"); await exit; } + } +} +try { await run("start"); await run("dev"); } +finally { await fs.rm(profile, { recursive: true, force: true }); } diff --git a/src/agent/chatgpt-bridge.ts b/src/agent/chatgpt-bridge.ts index 5412549..53d748d 100644 --- a/src/agent/chatgpt-bridge.ts +++ b/src/agent/chatgpt-bridge.ts @@ -1,4 +1,5 @@ import type { Model } from "@earendil-works/pi-ai"; +import { onWebSubscriptionPacket, webSubscriptionRequest, webSubscriptionsEnabled } from "./subscription-runtime.ts"; import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts"; import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts"; export type { ChatGPTStatus } from "../core/contracts/chatgpt.ts"; @@ -20,14 +21,15 @@ const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision] function desktopIPCConnected(): boolean { return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true; } +function runtimeConnected(): boolean { return desktopIPCConnected() || webSubscriptionsEnabled(); } function bridge(): BridgeState { const global = globalThis as typeof globalThis & { [key]?: BridgeState }; if (!global[key]) { const state: BridgeState = { status: unavailable(), stamp: { epoch: 0, authorityRevision: 0, revision: 0 }, models: [], catalogAccount: null, pending: new Map(), controllers: new Map(), listeners: new Set(), counter: 0, catalogRequest: 0 }; global[key] = state; - process.on("message", (raw: unknown) => { + const receive = (raw: unknown) => { const m = raw as { channel?: string; kind?: string; id?: string; op?: string; ok?: boolean; value?: unknown; stamp: Stamp; status: ChatGPTStatus; invalidate?: string; error?: string }; - if (!desktopIPCConnected() || m?.channel !== "lh:chatgpt") return; + if (m?.channel !== "lh:chatgpt") return; if (m.kind === "state" && validStamp(m.stamp) && m.stamp.revision >= state.stamp.revision) { acceptStatus(state, { ...m.stamp, status: m.status }); if (m.invalidate === "all") { state.catalogRequest++; clearCatalog(state); } @@ -43,8 +45,11 @@ function bridge(): BridgeState { if (m.ok === true) call.resolve(m.value); else call.reject(new Error(m.error ?? "ChatGPT connection failed.")); } - }); + }; + process.on("message", (raw: unknown) => { if (desktopIPCConnected()) receive(raw); }); + onWebSubscriptionPacket("lh:chatgpt", (packet) => { if (webSubscriptionsEnabled()) receive(packet); }); process.on("disconnect", () => { + if (webSubscriptionsEnabled()) return; state.status = unavailable(); clearCatalog(state); state.catalogRequest++; for (const controller of state.controllers.keys()) controller.abort("all"); for (const call of state.pending.values()) { clearTimeout(call.timer); call.reject(new Error("The desktop connection closed.")); } @@ -60,9 +65,10 @@ function acceptStatus(state: BridgeState, value: StatusValue) { if (value.epoch !== state.stamp.epoch || value.authorityRevision !== state.stamp.authorityRevision || value.status.activeId !== state.status.activeId) clearCatalog(state); state.status = value.status; state.stamp = { epoch: value.epoch, authorityRevision: value.authorityRevision, revision: value.revision }; } -/** Runtime-only inherited IPC. No HTTP credential route or renderer method. */ +/** Runtime-only: inherited desktop IPC or direct web owner. Never HTTP leases. */ export function chatGPTRequest(op: "status" | "models" | "lease", fields: Record = {}): Promise { const state = bridge(); + if (webSubscriptionsEnabled()) return webSubscriptionRequest("lh:chatgpt", op, fields); if (!desktopIPCConnected()) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection.")); return new Promise((resolve, reject) => { const id = `${process.pid}:${++state.counter}`; @@ -74,7 +80,7 @@ export function chatGPTRequest(op: "status" | "models" | "lease", fields: Rec }); } export function chatGPTStatus(): ChatGPTStatus { return bridge().status; } -export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return desktopIPCConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); } +export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return runtimeConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); } export function chatGPTBackgroundAllowed(): boolean { const s = chatGPTStatus(); return chatGPTConnected() && s.allowBackgroundUse === true; } export function onChatGPTChange(listener: () => void): () => void { bridge().listeners.add(listener); return () => { bridge().listeners.delete(listener); }; } export function chatGPTModels(): Model<"openai-responses">[] { return bridge().models; } @@ -86,7 +92,7 @@ export function chatGPTScope(selectedModelId: string): AdmissionScope { } export function chatGPTScopeCurrent(scope: AdmissionScope): boolean { const s = bridge(); - return desktopIPCConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId); + return runtimeConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId); } export async function chatGPTLease(scope: AdmissionScope, background: boolean): Promise { if (!chatGPTScopeCurrent(scope)) throw new Error("ChatGPT catalog authority changed"); @@ -108,7 +114,7 @@ export async function chatGPTDenial(scope: AdmissionScope | undefined, controlle /** Preserve account order; unknown capabilities/windows are never fabricated. */ export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise { const state = bridge(); - if (!desktopIPCConnected()) { clearCatalog(state); return; } + if (!runtimeConnected()) { clearCatalog(state); return; } const sequence = ++state.catalogRequest; try { const value = await chatGPTRequest("models"); diff --git a/src/agent/subscription-bridge.ts b/src/agent/subscription-bridge.ts index b1a558e..97ef6f8 100644 --- a/src/agent/subscription-bridge.ts +++ b/src/agent/subscription-bridge.ts @@ -1,4 +1,5 @@ import type { Api, Model, ModelAuth } from "@earendil-works/pi-ai"; +import { onWebSubscriptionPacket, webSubscriptionRequest, webSubscriptionsEnabled } from "./subscription-runtime.ts"; import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionScope, type NativeSubscriptionSnapshot, type NativeSubscriptionStatus } from "../core/contracts/subscriptions.ts"; import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts"; @@ -18,6 +19,7 @@ export function nativeSubscriptionId(logicalId: string): NativeSubscriptionProvi function desktopConnected(): boolean { return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true; } +function runtimeConnected(): boolean { return desktopConnected() || webSubscriptionsEnabled(); } function notify(state: State) { for (const listener of state.listeners) { try { listener(); } catch { /* observer isolation */ } } } function invalidate(state: State, providerId?: NativeSubscriptionProviderId) { for (const [controller, owner] of state.controllers) if (!providerId || owner.providerId === providerId) controller.abort("all"); @@ -60,7 +62,11 @@ function bridge(): State { if (message.ok) pending.resolve(message.value); else pending.reject(new Error(message.error ?? "Subscription connection failed.")); }); + onWebSubscriptionPacket("lh:subscriptions", (packet) => { + if (webSubscriptionsEnabled() && packet.snapshot) accept(state, packet.snapshot as NativeSubscriptionSnapshot); + }); process.on("disconnect", () => { + if (webSubscriptionsEnabled()) return; invalidate(state); for (const pending of state.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error("The desktop connection closed.")); } state.pending.clear(); notify(state); @@ -70,6 +76,7 @@ function bridge(): State { } export function subscriptionRequest(op: "snapshot" | "lease", fields: Record = {}): Promise { const state = bridge(); + if (webSubscriptionsEnabled()) return webSubscriptionRequest("lh:subscriptions", op, fields); if (!desktopConnected()) return Promise.reject(new Error("Subscription sign-in is available only in the desktop app with a private server connection.")); return new Promise((resolve, reject) => { const id = `${process.pid}:${++state.counter}`; @@ -82,17 +89,17 @@ export function subscriptionRequest(op: "snapshot" | "lease", fields: Record< } export async function refreshSubscriptionModels(): Promise { const state = bridge(); - if (!desktopConnected()) { invalidate(state); return; } + if (!runtimeConnected()) { invalidate(state); return; } try { accept(state, await subscriptionRequest("snapshot")); } catch { invalidate(state); notify(state); } } export function nativeSubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus | undefined { return bridge().statuses.get(providerId); } export function nativeSubscriptionConnected(providerId: NativeSubscriptionProviderId): boolean { const status = nativeSubscriptionStatus(providerId); - return desktopConnected() && status?.available === true && status.connected && !!status.connectionId; + return runtimeConnected() && status?.available === true && status.connected && !!status.connectionId; } export function nativeSubscriptionBackgroundAllowed(providerId: NativeSubscriptionProviderId): boolean { return nativeSubscriptionConnected(providerId) && nativeSubscriptionStatus(providerId)?.allowBackgroundUse === true; } -export function nativeSubscriptionModels(): Model[] { return desktopConnected() ? [...bridge().models.values()].flat() : []; } +export function nativeSubscriptionModels(): Model[] { return runtimeConnected() ? [...bridge().models.values()].flat() : []; } export function nativeSubscriptionModel(logicalId: string, modelId: string): Model | undefined { return nativeSubscriptionModels().find((m) => m.provider === logicalId && m.id === modelId); } export function onNativeSubscriptionChange(listener: () => void): () => void { const state = bridge(); state.listeners.add(listener); return () => { state.listeners.delete(listener); }; } export function subscriptionScope(providerId: NativeSubscriptionProviderId, selectedModelId: string): NativeSubscriptionScope { diff --git a/src/agent/subscription-runtime.ts b/src/agent/subscription-runtime.ts new file mode 100644 index 0000000..56e1f07 --- /dev/null +++ b/src/agent/subscription-runtime.ts @@ -0,0 +1,30 @@ +/** Server-injected private web transport. The agent layer never imports a + * filesystem owner or server composition root. Desktop IPC remains separate. */ +export type SubscriptionRuntimeChannel = "lh:chatgpt" | "lh:subscriptions"; +export type SubscriptionRuntimePacket = Record; +interface Runtime { + request?: (channel: SubscriptionRuntimeChannel, op: string, fields: Record) => Promise; + listeners: Map void>>; +} +const KEY = Symbol.for("lh.webSubscriptionRuntime"); +function runtime(): Runtime { + const global = globalThis as typeof globalThis & { [KEY]?: Runtime }; + return global[KEY] ??= { listeners: new Map() }; +} +export function configureWebSubscriptionRuntime(request: NonNullable): void { runtime().request = request; } +export function webSubscriptionsEnabled(): boolean { + return process.env.LH_DESKTOP_SERVER !== "1" && !process.env.LH_DESKTOP_API_SECRET && !!runtime().request; +} +export function onWebSubscriptionPacket(channel: SubscriptionRuntimeChannel, listener: (packet: SubscriptionRuntimePacket) => void): void { + const state = runtime(); const listeners = state.listeners.get(channel) ?? new Set(); + listeners.add(listener); state.listeners.set(channel, listeners); +} +export function publishWebSubscriptionPacket(channel: SubscriptionRuntimeChannel, packet: SubscriptionRuntimePacket): void { + for (const listener of runtime().listeners.get(channel) ?? []) { + try { listener(packet); } catch { /* observer isolation */ } + } +} +export function webSubscriptionRequest(channel: SubscriptionRuntimeChannel, op: string, fields: Record): Promise { + if (!webSubscriptionsEnabled()) return Promise.reject(new Error("The web subscription owner is unavailable.")); + return runtime().request!(channel, op, fields); +} diff --git a/src/core/events.ts b/src/core/events.ts index e639006..dba4236 100644 --- a/src/core/events.ts +++ b/src/core/events.ts @@ -38,6 +38,8 @@ export interface ServerEvents { "activity-changed": Record; /** The session list changed (create, delete, title). */ "chats-changed": Record; + /** Web subscription state changed. No account or credential data on the bus. */ + "subscriptions-changed": Record; /** A chat's record changed WITHOUT a turn (the silent dismissal write). * Same notify-then-fetch contract as the turn events: open views of * the chat refetch and adopt the durable record. */ @@ -61,6 +63,7 @@ export const SERVER_EVENT_TYPES = [ "jobs-changed", "activity-changed", "chats-changed", + "subscriptions-changed", "record-changed", "compaction-changed", ] as const satisfies readonly ServerEventType[]; diff --git a/src/server/index.ts b/src/server/index.ts index 49968ca..a04dfb5 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -43,4 +43,5 @@ export * from "./compaction.ts"; export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts"; export * from "./observability.ts"; export * from "./model-catalog.ts"; +export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts"; export { sessionCost } from "./pricing.ts"; diff --git a/src/server/web-subscription-http.ts b/src/server/web-subscription-http.ts new file mode 100644 index 0000000..3d5d7a9 --- /dev/null +++ b/src/server/web-subscription-http.ts @@ -0,0 +1,68 @@ +import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts"; +import { webSubscriptionOwner, webSubscriptionsEnabled, webSubscriptionStatus } from "./web-subscriptions.ts"; + +const HEADERS = { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff" }; +/** Same-origin browser controls, not user authentication. Web/dev deliberately + * retains the existing shared-server/API-key trust model. Never trust forwarded + * origins or add CORS grants here. A public deployment needs its own access gate. */ +export function subscriptionRequestOrigin(request: Request): string | undefined { + try { + const url = new URL(request.url); + const host = request.headers.get("host") ?? url.host; + const addressed = new URL(`${url.protocol}//${host}`); + if (!["http:", "https:"].includes(addressed.protocol) || addressed.username || addressed.password || addressed.pathname !== "/" || addressed.search || addressed.hash) return; + return addressed.origin; + } catch { return; } +} +function authorized(request: Request): boolean { + const origin = subscriptionRequestOrigin(request); + if (!origin || request.headers.get("x-learnharness-subscriptions") !== "1") return false; + if (request.headers.get("sec-fetch-site") === "cross-site") return false; + const supplied = request.headers.get("origin"); + return request.method === "GET" ? !supplied || supplied === origin : supplied === origin; +} +function error(message: string, status: number) { return Response.json({ error: message }, { status, headers: HEADERS }); } +export async function subscriptionControlsResponse(request: Request): Promise { + if (!webSubscriptionsEnabled()) return error("Web subscription controls are unavailable in desktop-owned mode.", 404); + if (!authorized(request)) return error("Subscription controls require a same-origin LearnHarness request.", 403); + try { + if (request.method === "GET") return Response.json(await webSubscriptionStatus(), { headers: HEADERS }); + if (request.method !== "POST") return error("Unsupported method.", 405); + if (request.headers.get("content-type")?.split(";")[0]?.trim() !== "application/json") return error("Expected JSON.", 415); + // Bound both advertised and streamed input before parsing commands. + if (Number(request.headers.get("content-length")) > 16384) return error("Request too large.", 413); + const reader = request.body?.getReader(); + if (!reader) return error("Expected a command.", 400); + const chunks: Uint8Array[] = []; let size = 0; + try { + for (;;) { + const next = await reader.read(); if (next.done) break; + size += next.value.byteLength; + if (size > 16384) { await reader.cancel(); return error("Request too large.", 413); } + chunks.push(next.value); + } + } finally { reader.releaseLock(); } + const command: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (!command || typeof command !== "object" || Array.isArray(command)) return error("Invalid command.", 400); + const fields = command as Record; + const providerId = fields.providerId; + if (providerId !== "chatgpt" && !NATIVE_SUBSCRIPTIONS.some((provider) => provider.providerId === providerId)) return error("Unsupported connection.", 400); + const allowed = providerId === "chatgpt" ? ["login", "cancel", "select", "disconnect", "logout", "consent"] : ["login", "cancel", "respond", "open", "retry", "disconnect", "consent"]; + if (typeof fields.op !== "string" || !allowed.includes(fields.op)) return error("Unsupported control.", 400); + return Response.json(await webSubscriptionOwner().control(providerId as string, fields, subscriptionRequestOrigin(request)!), { headers: HEADERS }); + } catch { + return error("The subscription connection could not be changed. Retry or reconnect in Providers.", 400); + } +} +/** One-use same-origin navigation. Full provider authorization URLs go only to + * a top-level browser redirect, never status JSON, the SSE bus or application JS. */ +export async function subscriptionBrowserResponse(request: Request): Promise { + if (!webSubscriptionsEnabled()) return error("Unavailable.", 404); + const origin = subscriptionRequestOrigin(request); + if (!origin || (request.method === "POST" ? !authorized(request) : request.headers.get("sec-fetch-site") !== "same-origin")) return error("Open sign-in from LearnHarness.", 403); + const id = new URL(request.url).searchParams.get("id"); + if (!id || !/^[0-9a-f-]{36}$/.test(id)) return error("This sign-in action is no longer available.", 410); + const target = await webSubscriptionOwner().takeBrowserAction(id, origin); + if (!target) return error("This sign-in action is no longer available.", 410); + return new Response(null, { status: 303, headers: { ...HEADERS, Location: target } }); +} diff --git a/src/server/web-subscriptions.ts b/src/server/web-subscriptions.ts new file mode 100644 index 0000000..4e40538 --- /dev/null +++ b/src/server/web-subscriptions.ts @@ -0,0 +1,127 @@ +/** Persistent Node web/dev owner. Never imported by client code. Desktop keeps + * its existing main-process owner; a broken desktop IPC channel cannot fall + * back to these files. One server process must own each configured auth dir. */ +import { AsyncLocalStorage } from "node:async_hooks"; +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import { AUTH_FILE } from "../../config.ts"; +import { createChatGPT, fileStore } from "../../electron/chatgpt.cjs"; +import { createSubscriptions } from "../../electron/subscriptions.cjs"; +import { chatGPTControl } from "../../electron/chatgpt-ipc.cjs"; +import { subscriptionControl } from "../../electron/subscription-ipc.cjs"; +import { emit } from "../core/events.ts"; +import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts"; +import type { NativeSubscriptionSnapshot } from "../core/contracts/subscriptions.ts"; +import { configureWebSubscriptionRuntime, publishWebSubscriptionPacket, webSubscriptionsEnabled, type SubscriptionRuntimeChannel as Channel, type SubscriptionRuntimePacket as Packet } from "../agent/subscription-runtime.ts"; +export { webSubscriptionsEnabled } from "../agent/subscription-runtime.ts"; +interface BrowserAction { target: string; origin: string; scope: string; expires: number; current(): Promise } +const KEY = Symbol.for("lh.webSubscriptions"); +interface Registry { + owner?: ReturnType; +} +function registry(): Registry { + const global = globalThis as typeof globalThis & { [KEY]?: Registry }; + return global[KEY] ??= {}; +} +function publish(channel: Channel, packet: Packet) { + publishWebSubscriptionPacket(channel, packet); + emit("subscriptions-changed", {}); +} +async function loadSDK() { + const [sdk, copilot, xai] = await Promise.all([ + import("@earendil-works/pi-ai"), + import("@earendil-works/pi-ai/providers/github-copilot"), + import("@earendil-works/pi-ai/providers/xai"), + ]); + return { ...sdk, ...copilot, ...xai }; +} + +/** Injectable factories/stores keep all test authentication offline. */ +export function createWebSubscriptionOwner(options: { + directory: string; + chatgptStore?: Parameters[0]["store"]; + sdk?: unknown; + onPacket?: (channel: Channel, packet: Packet) => void; +} ) { + const notify = options.onPacket ?? (() => {}); + const launches = new AsyncLocalStorage(); + const actions = new Map(); + let chatGPTLaunch = 0; + const openExternal = async (target: string) => { + const captured = launches.getStore(); + if (!captured || new URL(target).protocol !== "https:") throw new Error("No browser action is pending."); + captured.push(target); + }; + const chatgpt = createChatGPT({ + store: options.chatgptStore ?? fileStore(path.join(options.directory, "chatgpt-credentials.json")), + openExternal, + onChange: (packet?: Packet) => { if (packet) notify("lh:chatgpt", packet); }, + }); + const native = createSubscriptions({ + directory: options.directory, sdk: options.sdk, loadSDK, openExternal, + onChange: (snapshot: NativeSubscriptionSnapshot) => notify("lh:subscriptions", { channel: "lh:subscriptions", op: "changed", snapshot }), + }); + return { + chatgpt, native, + async control(providerId: string, command: Record, origin: string) { + const targets: string[] = []; + const value = await launches.run(targets, () => providerId === "chatgpt" + ? chatGPTControl(command, chatgpt) + : subscriptionControl({ ...command, providerId }, native)); + let browserAction: string | undefined; + const target = targets[0]; + if (target) { + const scope = providerId === "chatgpt" ? "chatgpt" : `${providerId}:${command.attemptId}:${command.actionId}`; + for (const [id, action] of actions) if (action.expires <= Date.now() || action.scope === scope) actions.delete(id); + const id = randomUUID(); + const generation = providerId === "chatgpt" ? ++chatGPTLaunch : chatGPTLaunch; + const current = providerId === "chatgpt" + ? async () => generation === chatGPTLaunch && (await chatgpt.status()).pending === true + : async () => (await native.status()).some((status: { providerId: string; interaction?: { attemptId: string; browser?: { id: string } } }) => status.providerId === providerId && status.interaction?.attemptId === command.attemptId && status.interaction?.browser?.id === command.actionId); + actions.set(id, { target, origin, scope, expires: Date.now() + 180000, current }); + browserAction = `/api/subscriptions/browser?id=${id}`; + } + return { value, ...(browserAction ? { browserAction } : {}) }; + }, + async takeBrowserAction(id: string, origin: string): Promise { + const action = actions.get(id); + if (!action || action.origin !== origin) return; + actions.delete(id); + if (action.expires <= Date.now() || !await action.current()) return; + return action.target; + }, + close() { actions.clear(); chatgpt.close(); native.close(); }, + }; +} +export function webSubscriptionOwner() { + if (!webSubscriptionsEnabled()) throw new Error("Web subscription controls are unavailable in desktop-owned mode."); + const state = registry(); + if (!state.owner) { + state.owner = createWebSubscriptionOwner({ directory: path.dirname(AUTH_FILE), onPacket: publish }); + process.once("exit", () => state.owner?.close()); + } + return state.owner; +} +/** Private runtime calls only: no HTTP lease/token operation exists. */ +export async function webSubscriptionRequest(channel: Channel, op: string, fields: Record): Promise { + const owner = webSubscriptionOwner(); + let value: unknown; + if (channel === "lh:chatgpt") { + if (op === "status") value = { status: await owner.chatgpt.status(), ...owner.chatgpt.stamp() }; + else if (op === "models") value = await owner.chatgpt.models(); + else if (op === "lease" && typeof fields.background === "boolean") value = await owner.chatgpt.lease(fields.background, fields); + else throw new Error("Invalid private operation."); + } else { + if (op === "snapshot") value = await owner.native.snapshot(); + else if (op === "lease" && typeof fields.background === "boolean") value = await owner.native.lease(fields.scope, fields.background); + else throw new Error("Invalid private operation."); + } + return value as T; +} +configureWebSubscriptionRuntime(webSubscriptionRequest); + +export async function webSubscriptionStatus(): Promise<{ chatgpt: ChatGPTStatus; native: NativeSubscriptionSnapshot["statuses"] }> { + const owner = webSubscriptionOwner(); + const [chatgpt, native] = await Promise.all([owner.chatgpt.status(), owner.native.status()]); + return { chatgpt, native }; +} diff --git a/tests/chatgpt-dev-ipc.test.ts b/tests/chatgpt-dev-ipc.test.ts index fcb0e44..e940b01 100644 --- a/tests/chatgpt-dev-ipc.test.ts +++ b/tests/chatgpt-dev-ipc.test.ts @@ -12,7 +12,7 @@ const { GET } = await import("../app/api/models/route.ts"); const { chatGPTRequest, chatGPTConnected, chatGPTModels } = await import("../src/agent/chatgpt-bridge.ts"); after(() => fs.rmSync(scratch, { recursive: true, force: true })); -test("Next-style connected IPC is not desktop transport and never delays keyed API catalogs", async () => { +test("Next-style IPC is not desktop transport; web ownership works without delaying keyed catalogs", async () => { const send = Object.getOwnPropertyDescriptor(process, "send"); const connected = Object.getOwnPropertyDescriptor(process, "connected"); const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET; @@ -35,7 +35,11 @@ test("Next-style connected IPC is not desktop transport and never delays keyed A assert.equal(catalog.providers.find((p: { id: string }) => p.id === "chatgpt").keyPresent, false); assert.equal(chatGPTConnected(), false); assert.deepEqual(chatGPTModels(), []); - await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/); + if (!mode && !capability) { + const value = await chatGPTRequest<{ status: { available: boolean; accounts: unknown[] } }>("status"); + assert.equal(value.status.available, true); + assert.deepEqual(value.status.accounts, []); + } else await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/); assert.equal(sends, 0, "no desktop request/timer may be started over framework IPC"); } process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64); diff --git a/tests/desktop-platform.test.ts b/tests/desktop-platform.test.ts index 040a47a..03276c0 100644 --- a/tests/desktop-platform.test.ts +++ b/tests/desktop-platform.test.ts @@ -15,7 +15,7 @@ const mainRequire = createRequire(mainFile); /** Decline the single-instance lock so normal boot never spawns a server. * Then call the REAL createWindow with a stub Electron API and inspect its * constructor options. No rewritten copy of the production logic. */ -function windowOptions(platform: string) { +function windowOptions(platform: string, env: Record = {}) { class FakeWindow { constructor(readonly options: unknown) {} webContents = { on() {}, setWindowOpenHandler() {} }; @@ -23,13 +23,13 @@ function windowOptions(platform: string) { once() {} } const electron = { - app: { setName() {}, requestSingleInstanceLock: () => false, quit() {} }, + app: { setName() {}, getPath: () => "/fixture/LearnHarness", setPath() {}, requestSingleInstanceLock: () => false, quit() {} }, BrowserWindow: FakeWindow, session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler() {} } }, }; const context = { require: (id: string) => id === "electron" ? electron : mainRequire(id), - process: { platform, argv: [], env: {}, on() {} }, + process: { platform, argv: [], env, on() {} }, __dirname: dirname(mainFile), }; runInNewContext(mainSource, context, { filename: mainFile }); @@ -61,6 +61,24 @@ describe("desktop window chrome", () => { }); } + it("only the external dev shell receives the web subscription transport marker", () => { + assert.equal(windowOptions("darwin").webPreferences.additionalArguments, undefined); + assert.deepEqual(windowOptions("darwin", { LH_DEV_URL: "http://localhost:3000" }).webPreferences.additionalArguments, ["--lh-web-subscriptions"]); + }); + + it("dev preload routes opaque sign-in actions through its narrow external-browser channel", async () => { + let bridge!: { subscriptionTransport: string; openSubscriptionBrowser(action: string): Promise }; + const invoked: unknown[][] = []; + runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), { + process: { platform: "darwin", argv: ["--lh-web-subscriptions"] }, + require: () => ({ contextBridge: { exposeInMainWorld: (_: string, value: typeof bridge) => { bridge = value; } }, ipcRenderer: { on() {}, invoke: async (...args: unknown[]) => { invoked.push(args); } } }), + }); + assert.equal(bridge.subscriptionTransport, "web"); + const action = "/api/subscriptions/browser?id=00000000-0000-4000-8000-000000000001"; + await bridge.openSubscriptionBrowser(action); + assert.deepEqual(invoked, [["lh:subscriptions:web-browser", action]]); + }); + it("preload exposes OS identity without exposing Node/Electron to the renderer", () => { let bridge: Record = {}; const context = { diff --git a/tests/provider-tracing.test.ts b/tests/provider-tracing.test.ts index e251c8d..0c255e4 100644 --- a/tests/provider-tracing.test.ts +++ b/tests/provider-tracing.test.ts @@ -16,7 +16,7 @@ describe("standalone JavaScript tracing exclusions and native workaround policy" const file = (relative: string) => root + (windows ? "\\" : "/") + (windows ? relative.replaceAll("/", "\\") : relative); const ignored = picomatch(patterns, { contains: true, dot: true, windows }); it(`excludes genuine data directories and secrets (${windows ? "Windows" : "POSIX"})`, () => { - for (const relative of ["data/canary.json", "nested/data/private.json", "auth.json", ".env", ".env.local"]) { + for (const relative of ["data/canary.json", "nested/data/private.json", "auth.json", ".env", ".env.local", "chatgpt-credentials.json", "github-copilot-credentials.json", "xai-credentials.json", "chatgpt-credentials.json.fixture.tmp", "github-copilot-credentials.json.fixture.tmp", "xai-credentials.json.fixture.tmp"]) { assert.equal(ignored(file(relative)), true, relative); } // The explicit includes remain necessary: this raw ignore still matches SDK data. @@ -37,7 +37,7 @@ describe("standalone JavaScript tracing exclusions and native workaround policy" assert.equal(ignore(`${root}/node_modules/gcp-metadata/build/src/index.js`), false); }); it("preserves all secret exclusions exactly", () => { - assert.deepEqual(patterns, ["**/data/**/*", "auth.json", ".env*"]); + assert.deepEqual(patterns, ["**/data/**/*", "auth.json", "chatgpt-credentials.json*", "github-copilot-credentials.json*", "xai-credentials.json*", ".env*"]); }); it("retains exactly the narrow native-Turbopack closure workaround and existing runtime includes", () => { assert.deepEqual(nextConfig.outputFileTracingIncludes!["*"], [ diff --git a/tests/web-subscription-client.test.tsx b/tests/web-subscription-client.test.tsx new file mode 100644 index 0000000..df5e2a8 --- /dev/null +++ b/tests/web-subscription-client.test.tsx @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import test, { afterEach } from "node:test"; +import { parseHTML } from "linkedom"; +import { createNotifyHub } from "../components/server-events.tsx"; +import { createWebChatGPTBridge, createWebNativeBridge } from "../components/settings/subscription-client.ts"; +import { createRequire } from "node:module"; + +const dom = parseHTML(""); +Object.assign(globalThis, { window: dom.window, document: dom.document }); +const originalFetch = globalThis.fetch; +const action = "/api/subscriptions/browser?id=00000000-0000-4000-8000-000000000001"; +afterEach(() => { globalThis.fetch = originalFetch; delete window.lh; }); + +test("web ChatGPT reserves a new tab before async login, navigates only to an opaque action and receives push/resync", async () => { + const calls: string[] = []; + const tab = { opener: {}, closed: false, location: { href: "about:blank" }, close() { this.closed = true; } }; + window.open = (() => { calls.push("tab"); return tab; }) as unknown as typeof window.open; + const safe = { available: true, pending: true, allowBackgroundUse: true, activeId: null, accounts: [], error: null }; + globalThis.fetch = async (url, options) => { + assert.equal(url, "/api/subscriptions"); assert.equal(new Headers(options?.headers).get("x-learnharness-subscriptions"), "1"); + if (!options?.method) return Response.json({ chatgpt: safe, native: [] }); + calls.push("login"); assert.equal(tab.opener, null); + assert.deepEqual(JSON.parse(String(options.body)), { providerId: "chatgpt", op: "login" }); + return Response.json({ value: safe, browserAction: action }); + }; + const hub = createNotifyHub(), bridge = createWebChatGPTBridge(hub); + assert.deepEqual(await bridge.login(), safe); assert.deepEqual(calls, ["tab", "login"]); + assert.equal(tab.location.href, action); assert(!tab.closed); + let updates = 0; const off = bridge.onChange(() => updates++); + hub.dispatch("subscriptions-changed", "{}"); hub.fireOpen(); assert.equal(updates, 2); + off(); hub.fireOpen(); assert.equal(updates, 2); + assert.deepEqual(await bridge.status(), safe); +}); + +test("blocked popups do not start OAuth; failed login closes the reserved tab", async () => { + const bridge = createWebChatGPTBridge(null); + window.open = () => null; + globalThis.fetch = async () => assert.fail("blocked popup must not start sign-in"); + await assert.rejects(bridge.login(), /Allow a sign-in tab/); + const tab = { opener: {}, closed: false, close() { this.closed = true; } }; + window.open = (() => tab) as unknown as typeof window.open; + globalThis.fetch = async () => new Response(null, { status: 400 }); + await assert.rejects(bridge.login()); assert(tab.closed); +}); + +test("native web login retains device prompts; only the explicit Open browser action opens a tab", async () => { + const requests: Record[] = []; let tabs = 0; + const tab = { opener: {}, closed: false, location: { href: "" }, close() {} }; + window.open = (() => { tabs++; return tab; }) as unknown as typeof window.open; + globalThis.fetch = async (_url, options) => { + const command = JSON.parse(String(options?.body)); requests.push(command); + return Response.json(command.op === "open" ? { browserAction: action } : {}); + }; + const bridge = createWebNativeBridge(null); + await bridge.login("github-copilot", false); assert.equal(tabs, 0); + await bridge.respond("github-copilot", "attempt", "prompt", ""); + await bridge.open("github-copilot", "attempt", "action"); assert.equal(tabs, 1); assert.equal(tab.location.href, action); + assert.deepEqual(requests, [{ providerId: "github-copilot", op: "login", enabled: false }, { providerId: "github-copilot", op: "respond", attemptId: "attempt", promptId: "prompt", value: "" }, { providerId: "github-copilot", op: "open", attemptId: "attempt", actionId: "action" }]); +}); + +test("Electron dev uses its narrow external-browser bridge, without attempting a renderer popup", async () => { + const opened: string[] = []; + window.lh = { expandWindow() {}, subscriptionTransport: "web", openSubscriptionBrowser: async (target) => { opened.push(target); } }; + window.open = () => assert.fail("Electron denies renderer popups"); + globalThis.fetch = async () => Response.json({ value: { pending: true }, browserAction: action }); + await createWebChatGPTBridge(null).login(); assert.deepEqual(opened, [action]); +}); + +test("web notices identify the server machine and same-machine ChatGPT sign-in; desktop copy stays unchanged", async () => { + const { CHATGPT_NOTICE, chatGPTNotice } = await import("../components/settings/chatgpt-connection.tsx"); + const { subscriptionNotice, subscriptionDisconnectCopy } = await import("../components/settings/subscription-connections.tsx"); + assert.equal(chatGPTNotice(), CHATGPT_NOTICE); assert(CHATGPT_NOTICE.includes("on this device")); + assert(chatGPTNotice(true).includes("on the machine running LearnHarness")); + assert(chatGPTNotice(true).endsWith("Complete ChatGPT sign-in in a browser on that same machine.")); + for (const label of ["GitHub Copilot", "Grok"]) { + assert(subscriptionNotice(label).includes("stored locally")); assert(subscriptionDisconnectCopy(label).includes("from this device")); + assert(subscriptionNotice(label, true).includes("on the machine running LearnHarness")); + assert(subscriptionDisconnectCopy(label, true).includes("from the machine running LearnHarness")); + } +}); + +const require = createRequire(import.meta.url); +const { openWebSubscriptionBrowser } = require("../electron/web-subscription-browser.cjs"); +test("desktop-dev opener accepts only local action IDs, does not follow redirects or use browser cookies", async () => { + const opened: string[] = []; + const options = { + openExternal: async (url: string) => { opened.push(url); }, + fetch: async (url: URL, init: RequestInit) => { + assert.equal(url.origin, "http://localhost:3000"); assert.equal(url.pathname, "/api/subscriptions/browser"); + assert.equal(init.method, "POST"); assert.equal(init.redirect, "manual"); assert.equal(init.credentials, "omit"); + assert.equal(new Headers(init.headers).get("origin"), "http://localhost:3000"); + return new Response(null, { status: 303, headers: { Location: "https://auth.openai.com/authorize?state=fixture" } }); + }, + }; + await openWebSubscriptionBrowser(action, "http://localhost:3000", options); assert.equal(opened.length, 1); + for (const target of ["https://unrelated.invalid", "//unrelated.invalid", "/api/keys", `${action}&target=evil`]) await assert.rejects(openWebSubscriptionBrowser(target, "http://localhost:3000", options), /Invalid sign-in action/); + for (const target of ["file:///private/fixture", "http://unrelated.invalid", "https://user:pass@unrelated.invalid"]) await assert.rejects(openWebSubscriptionBrowser(action, "http://localhost:3000", { ...options, fetch: async () => new Response(null, { status: 303, headers: { Location: target } }) })); + assert.equal(opened.length, 1); +}); diff --git a/tests/web-subscription-payload.test.ts b/tests/web-subscription-payload.test.ts new file mode 100644 index 0000000..d860660 --- /dev/null +++ b/tests/web-subscription-payload.test.ts @@ -0,0 +1,21 @@ +/** Exercise the real curator against synthetic payloads, never the repo payload. */ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, copyFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +for (const name of ["chatgpt-credentials.json", "github-copilot-credentials.json", "xai-credentials.json"]) { + for (const secret of [name, `${name}.fixture.tmp`]) test(`payload curator refuses web credentials: ${secret}`, () => { + const root = mkdtempSync(path.join(tmpdir(), "lh-sub-payload-")); + try { + mkdirSync(path.join(root, "scripts")); mkdirSync(path.join(root, ".next", "standalone"), { recursive: true }); mkdirSync(path.join(root, ".next", "static")); + copyFileSync(new URL("../scripts/build-server.mjs", import.meta.url), path.join(root, "scripts", "build-server.mjs")); + writeFileSync(path.join(root, ".next", "standalone", "server.js"), "// fixture"); + writeFileSync(path.join(root, ".next", "standalone", secret), "fixture-not-real-credentials", { mode: 0o600 }); + const result = spawnSync(process.execPath, [path.join(root, "scripts", "build-server.mjs")], { encoding: "utf8", env: process.env }); + assert.equal(result.status, 1); assert(result.stderr.includes(`REFUSING: standalone contains ${secret}`)); + } finally { rmSync(root, { recursive: true, force: true }); } + }); +} diff --git a/tests/web-subscriptions.test.ts b/tests/web-subscriptions.test.ts new file mode 100644 index 0000000..f34141f --- /dev/null +++ b/tests/web-subscriptions.test.ts @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import test, { after } from "node:test"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { createRequire } from "node:module"; +import { createModels, type OAuthCredential, type ProviderAuthInteraction } from "@earendil-works/pi-ai"; +import { githubCopilotProvider } from "@earendil-works/pi-ai/providers/github-copilot"; +import { xaiProvider } from "@earendil-works/pi-ai/providers/xai"; + +const directory = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-sub-")); +process.env.LH_AUTH_FILE = path.join(directory, "auth.json"); +process.env.LH_DATA_DIR = path.join(directory, "data"); +delete process.env.LH_DESKTOP_SERVER; delete process.env.LH_DESKTOP_API_SECRET; +await fs.writeFile(process.env.LH_AUTH_FILE, "{}", { mode: 0o600 }); +const require = createRequire(import.meta.url); +const { SCOPES } = require("../electron/chatgpt.cjs"); +const { createWebSubscriptionOwner, webSubscriptionOwner, webSubscriptionsEnabled } = await import("../src/server/web-subscriptions.ts"); +const { subscriptionControlsResponse, subscriptionBrowserResponse } = await import("../src/server/web-subscription-http.ts"); +const chat = await import("../src/agent/chatgpt-bridge.ts"); +const native = await import("../src/agent/subscription-bridge.ts"); +const { piModels } = await import("../src/agent/models.ts"); +const { refreshConnectionCatalog } = await import("../src/server/model-catalog.ts"); +const model = piModels().getModels("openai").find((entry) => entry.api === "openai-responses")!; +const originalFetch = globalThis.fetch; +globalThis.fetch = async (input) => { + assert.equal(String(input), "https://api.openai.com/v1/models", "unexpected network request forbidden"); + return Response.json({ models: [{ slug: model.id, display_name: "Fixture ChatGPT", visibility: "list" }] }); +}; +const copilot = githubCopilotProvider().getModels()[0]!; +const credentials = (id: string): OAuthCredential => ({ type: "oauth", access: id === "xai" ? "fixture-xai-access" : "tid=fixture;proxy-ep=proxy.individual.githubcopilot.com;", refresh: "fixture-refresh", expires: Date.now() + 3600000, ...(id === "github-copilot" ? { availableModelIds: [copilot.id] } : {}) }); +await fs.writeFile(path.join(directory, "chatgpt-credentials.json"), JSON.stringify({ version: 1, hostId: "fixture-host", allowBackgroundUse: true, activeId: "fixture-account", registrations: [{ id: "fixture-account", clientId: "oaiapp_fixture", issuer: "https://auth.openai.com", subject: "fixture-subject", email: "fixture@example.invalid", tokens: { accessToken: "fixture-chatgpt-access", refreshToken: "fixture-chatgpt-refresh", idToken: "fixture-id-token", scopes: SCOPES, expiresAt: Date.now() + 3600000 } }] }), { mode: 0o600 }); +for (const id of ["github-copilot", "xai"]) await fs.writeFile(path.join(directory, `${id}-credentials.json`), JSON.stringify({ version: 1, allowBackgroundUse: true, connectionId: "00000000-0000-4000-8000-000000000001", credential: credentials(id) }), { mode: 0o600 }); +function request(command?: Record, origin = "http://192.168.1.10:3000") { + return new Request(`${origin}/api/subscriptions`, { method: command ? "POST" : "GET", headers: { host: new URL(origin).host, origin, "sec-fetch-site": "same-origin", "x-learnharness-subscriptions": "1", "content-type": "application/json" }, ...(command ? { body: JSON.stringify(command) } : {}) }); +} +after(async () => { webSubscriptionOwner().close(); globalThis.fetch = originalFetch; await fs.rm(directory, { recursive: true, force: true }); }); + +test("ordinary web restores configured sibling credentials, private runtime leases work for LAN-owned requests", async () => { + await refreshConnectionCatalog(); + assert(chat.chatGPTConnected()); assert(native.nativeSubscriptionConnected("github-copilot")); assert(native.nativeSubscriptionConnected("xai")); + assert.equal((await chat.chatGPTLease(chat.chatGPTScope(model.id), false)).accessToken, "fixture-chatgpt-access"); + const xaiModel = native.nativeSubscriptionModels().find((entry) => entry.provider === "xai-subscription")!; + assert.equal((await native.subscriptionLease(native.subscriptionScope("xai", xaiModel.id), false)).apiKey, "fixture-xai-access"); + const response = await subscriptionControlsResponse(request()); + assert.equal(response.status, 200); assert.equal(response.headers.get("cache-control"), "no-store"); + const publicState = await response.text(); + for (const secret of ["fixture-chatgpt-access", "fixture-chatgpt-refresh", "fixture-id-token", "fixture-xai-access", "fixture-refresh"]) assert(!publicState.includes(secret)); + assert(publicState.includes("fixture@example.invalid")); +}); + +test("web background OFF immediately aborts background streams, retains foreground and rejects private background leases", async () => { + const foreground = chat.chatGPTAbortController(false), background = chat.chatGPTAbortController(true); + const nativeForeground = native.subscriptionAbortController("xai", false), nativeBackground = native.subscriptionAbortController("xai", true); + try { + assert.equal((await subscriptionControlsResponse(request({ providerId: "chatgpt", op: "consent", enabled: false }))).status, 200); + assert(background.controller.signal.aborted); assert(!foreground.controller.signal.aborted); + const scope = chat.chatGPTScope(model.id); await chat.chatGPTLease(scope, false); await assert.rejects(chat.chatGPTLease(scope, true)); + assert.equal((await subscriptionControlsResponse(request({ providerId: "xai", op: "consent", enabled: false }))).status, 200); + assert(nativeBackground.controller.signal.aborted); assert(!nativeForeground.controller.signal.aborted); + const xaiModel = native.nativeSubscriptionModels().find((entry) => entry.provider === "xai-subscription")!; + const nativeScope = native.subscriptionScope("xai", xaiModel.id); await native.subscriptionLease(nativeScope, false); await assert.rejects(native.subscriptionLease(nativeScope, true)); + await subscriptionControlsResponse(request({ providerId: "xai", op: "disconnect" })); + assert(nativeForeground.controller.signal.aborted); assert(!native.nativeSubscriptionConnected("xai")); + assert.deepEqual(JSON.parse(await fs.readFile(path.join(directory, "xai-credentials.json"), "utf8")), { version: 1, allowBackgroundUse: false }); + } finally { foreground.release(); background.release(); nativeForeground.release(); nativeBackground.release(); } +}); + +test("HTTP controls reject cross-origin, simple-form and private credential operations; desktop never falls back", async () => { + const forged = request({ providerId: "chatgpt", op: "consent", enabled: true }); forged.headers.set("origin", "https://unrelated.invalid"); + assert.equal((await subscriptionControlsResponse(forged)).status, 403); + const noHeader = request(); noHeader.headers.delete("x-learnharness-subscriptions"); + assert.equal((await subscriptionControlsResponse(noHeader)).status, 403); + const noOrigin = request({ providerId: "chatgpt", op: "cancel" }); noOrigin.headers.delete("origin"); + assert.equal((await subscriptionControlsResponse(noOrigin)).status, 403); + for (const op of ["lease", "models", "snapshot", "token", "openExternal"]) assert.equal((await subscriptionControlsResponse(request({ providerId: "chatgpt", op }))).status, 400); + const huge = request({ providerId: "chatgpt", op: "cancel", padding: "x".repeat(16384) }); + assert.equal((await subscriptionControlsResponse(huge)).status, 413); + const browser = new Request("http://localhost:3000/api/subscriptions/browser?id=00000000-0000-4000-8000-000000000001"); + assert.equal((await subscriptionBrowserResponse(browser)).status, 403); + for (const [marker, secret] of [["1", undefined], [undefined, "fixture-capability"]]) { + if (marker) process.env.LH_DESKTOP_SERVER = marker; else delete process.env.LH_DESKTOP_SERVER; + if (secret) process.env.LH_DESKTOP_API_SECRET = secret; else delete process.env.LH_DESKTOP_API_SECRET; + assert(!webSubscriptionsEnabled()); + assert.equal((await subscriptionControlsResponse(request())).status, 404); + await assert.rejects(chat.chatGPTRequest("status"), /desktop app/); + } + delete process.env.LH_DESKTOP_SERVER; delete process.env.LH_DESKTOP_API_SECRET; +}); + +function fakeSDK(login: (id: string, interaction: ProviderAuthInteraction) => Promise) { + const factory = (id: string) => { + const provider = id === "xai" ? xaiProvider() : githubCopilotProvider(); + return { ...provider, auth: { oauth: { ...provider.auth.oauth!, login: (interaction: ProviderAuthInteraction) => login(id, interaction), refresh: async () => credentials(id) } } }; + }; + return { createModels, githubCopilotProvider: () => factory("github-copilot"), xaiProvider: () => factory("xai") }; +} +test("device browser actions preserve complete URLs privately, are origin-bound, one-use and cancellation-bound", async () => { + const scratch = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-action-")); + let complete!: (credential: OAuthCredential) => void; + const pending = new Promise((resolve) => { complete = resolve; }); + let notified!: () => void; + const notification = new Promise((resolve) => { notified = resolve; }); + const owner = createWebSubscriptionOwner({ directory: scratch, sdk: fakeSDK(async (_id, interaction) => { interaction.notify({ type: "device_code", userCode: "PUBLIC", verificationUri: "https://auth.x.ai/device?user_code=PUBLIC" }); notified(); return pending; }) }); + try { + const login = owner.control("xai", { op: "login" }, "http://localhost:3000"); await notification; + const interaction = (await owner.native.status()).find((entry: { providerId: string }) => entry.providerId === "xai")!.interaction; + const command = { op: "open", attemptId: interaction.attemptId, actionId: interaction.browser.id }; + const opened = await owner.control("xai", command, "http://localhost:3000"); + assert(opened.browserAction); assert(!JSON.stringify(opened).includes("auth.x.ai")); + const id = new URL(opened.browserAction, "http://localhost:3000").searchParams.get("id")!; + assert.equal(await owner.takeBrowserAction(id, "http://other.invalid"), undefined); + assert.equal(await owner.takeBrowserAction(id, "http://localhost:3000"), "https://auth.x.ai/device?user_code=PUBLIC"); + assert.equal(await owner.takeBrowserAction(id, "http://localhost:3000"), undefined); + const stale = await owner.control("xai", command, "http://localhost:3000"); + await owner.control("xai", { op: "cancel" }, "http://localhost:3000"); + complete(credentials("xai")); await login; + assert.equal(await owner.takeBrowserAction(new URL(stale.browserAction!, "http://localhost:3000").searchParams.get("id")!, "http://localhost:3000"), undefined); + } finally { owner.close(); await fs.rm(scratch, { recursive: true, force: true }); } +}); + +test("web ChatGPT login supplies only an opaque browser action; cancel/replacement invalidates earlier launch", async () => { + const scratch = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-chat-action-")); + const owner = createWebSubscriptionOwner({ directory: scratch, sdk: fakeSDK(async (id) => credentials(id)) }); + try { + const first = await owner.control("chatgpt", { op: "login" }, "http://localhost:3000"); + assert(first.browserAction); assert(!JSON.stringify(first).includes("auth.openai.com")); + await owner.control("chatgpt", { op: "cancel" }, "http://localhost:3000"); + const second = await owner.control("chatgpt", { op: "login" }, "http://localhost:3000"); + assert(second.browserAction); + const firstId = new URL(first.browserAction, "http://localhost:3000").searchParams.get("id")!; + assert.equal(await owner.takeBrowserAction(firstId, "http://localhost:3000"), undefined); + const secondId = new URL(second.browserAction, "http://localhost:3000").searchParams.get("id")!; + const target = new URL((await owner.takeBrowserAction(secondId, "http://localhost:3000"))!); + assert.equal(target.origin, "https://auth.openai.com"); + const callback = new URL(target.searchParams.get("redirect_uri")!); + assert.equal(callback.hostname, "127.0.0.1"); assert.equal(callback.pathname, "/auth/callback"); + assert(target.searchParams.has("state")); assert(target.searchParams.has("code_challenge")); + } finally { owner.close(); await fs.rm(scratch, { recursive: true, force: true }); } +}); From d3c8709bd2ca6c0dde7019d7377e0ddb7cbca7c2 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 26 Oct 2026 04:49:47 +0200 Subject: [PATCH 27/87] fix: preserve source data and recover tool math at presentation --- components/ai-elements/message.tsx | 12 +- components/answered-card.tsx | 25 +- components/practice-cards.tsx | 10 +- components/practice-dock.tsx | 5 +- components/question-dock.tsx | 15 +- docs/tool-content-recovery.md | 63 ++ package-lock.json | 3 + package.json | 3 + src/core/answer-presentation.ts | 57 ++ src/core/content-reader.ts | 215 ++++++ src/core/index.ts | 1 + src/core/math-delims.ts | 665 +++--------------- src/core/tool-parts.ts | 32 +- tests/content-recovery.test.ts | 116 +++ .../document-percent-regressions.json | 90 +++ .../tool-rendering/matrix-regressions.json | 135 ++++ .../mixed-escaping-regressions.json | 19 + tests/repair-math-display.test.tsx | 16 +- tests/tool-content-interaction.test.tsx | 78 ++ tests/tool-content-presentation.test.tsx | 164 +++++ tests/tool-parts.test.ts | 62 +- 21 files changed, 1134 insertions(+), 652 deletions(-) create mode 100644 docs/tool-content-recovery.md create mode 100644 src/core/answer-presentation.ts create mode 100644 src/core/content-reader.ts create mode 100644 tests/content-recovery.test.ts create mode 100644 tests/fixtures/tool-rendering/document-percent-regressions.json create mode 100644 tests/fixtures/tool-rendering/matrix-regressions.json create mode 100644 tests/fixtures/tool-rendering/mixed-escaping-regressions.json create mode 100644 tests/tool-content-interaction.test.tsx create mode 100644 tests/tool-content-presentation.test.tsx diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx index e4b533b..9e22382 100644 --- a/components/ai-elements/message.tsx +++ b/components/ai-elements/message.tsx @@ -34,7 +34,7 @@ import { type PluginConfig, } from "streamdown"; import { mdComponents } from "@/components/md-blocks"; -import { transformMathDelimiters } from "@/src/core"; +import { repairMathForDisplay, transformMathDelimiters } from "@/src/core"; export type MessageProps = HTMLAttributes & { from: LhViewMessage["role"]; @@ -388,6 +388,16 @@ export const MessageResponse = memo( MessageResponse.displayName = "MessageResponse"; +/** Recovery is a tool-text presentation concern, not a shape/answer-data + * transform. Ordinary assistant/user prose keeps MessageResponse unchanged. */ +export function ToolContentResponse({ children, ...props }: MessageResponseProps) { + return ( + + {typeof children === "string" ? repairMathForDisplay(children) : children} + + ); +} + export type MessageToolbarProps = ComponentProps<"div">; export const MessageToolbar = ({ diff --git a/components/answered-card.tsx b/components/answered-card.tsx index f281e33..ce76b80 100644 --- a/components/answered-card.tsx +++ b/components/answered-card.tsx @@ -5,9 +5,9 @@ import { ConfidenceRail } from "@/components/confidence-rail"; import { GlyphMark } from "@/components/ui/glyph-mark"; import { ThinkingBlock } from "@/components/thinking-block"; import { cn } from "@/lib/utils"; -import { MessageResponse } from "@/components/ai-elements/message"; +import { ToolContentResponse } from "@/components/ai-elements/message"; import type { QuestionAnswerData } from "@/src/core"; -import { parseAskInput, parseQuizInput } from "@/src/core"; +import { answerForPresentation, parseAskInput, parseQuizInput } from "@/src/core"; /** * Static rendering of an answered question (tool part in `output-available`). @@ -19,7 +19,8 @@ import { parseAskInput, parseQuizInput } from "@/src/core"; * client — the card reveals his answer, the committed correct answer, and * the explanation, with NO verdict chips (owner ruling 2026-09-27). */ -export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; input?: unknown }) { +export function AnsweredCard({ answer: savedAnswer, input }: { answer: QuestionAnswerData; input?: unknown }) { + const answer = answerForPresentation(savedAnswer, input); const [whyOpen, setWhyOpen] = useState(false); // the tucked explanation (predictions, owner 2026-10-03) if (answer.dismissed) { return ( @@ -86,13 +87,13 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in ))}
- {answer.questionText} + {answer.questionText}
{parsedDetails && ( // The details line, same paint as the pending dock (owner, // 2026-10-12): full markdown, LaTeX included.
- {parsedDetails} + {parsedDetails}
)} @@ -131,14 +132,14 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in )} - {opt.label} + {opt.label} {descByIndex.get(opt.index) && ( // Ask-option subtext (owner, 2026-10-12): same markdown path // as the pending dock — LaTeX included. Quiz options carry // none (owner ruling 2026-09-26); the map stays empty for them. - + {descByIndex.get(opt.index)} - + )}
@@ -211,9 +212,9 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in {whyOpen && (
Why: - {/* The markdown wrapper must go truly inline — a block

after the - "Why:" label created the oversized leading gap (owner's fix list). */} - {answer.explanation} + {/* Keep the first paragraph beside the label, not every paragraph: + later paragraphs must retain their recovered document structure. */} + {answer.explanation}

)}
@@ -225,7 +226,7 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in {isExplain && answer.committed && (
The answer: - {answer.committed} + {answer.committed}
)}
diff --git a/components/practice-cards.tsx b/components/practice-cards.tsx index 08cd053..eb4e507 100644 --- a/components/practice-cards.tsx +++ b/components/practice-cards.tsx @@ -1,8 +1,8 @@ "use client"; -import { MessageResponse } from "@/components/ai-elements/message"; +import { ToolContentResponse } from "@/components/ai-elements/message"; import { cn } from "@/lib/utils"; -import { repairMathForDisplay, type ArtifactPartProps } from "@/src/core"; +import type { ArtifactPartProps } from "@/src/core"; /** * The practice transcript cards (practice sessions, owner design @@ -66,7 +66,7 @@ export function ProblemCard({ part }: ArtifactPartProps) {
Problem
- {repairMathForDisplay(statement)} + {statement}
); @@ -133,7 +133,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) { (owner round 3): the same quiet 50% register as quiz explanations. */} {verdict.length > 0 && (
- {repairMathForDisplay(verdict)} + {verdict}
)} {/* Then the divider, then the labeled model solution (Slice G: the @@ -144,7 +144,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
Model solution
- {repairMathForDisplay(reveal)} + {reveal}
diff --git a/components/practice-dock.tsx b/components/practice-dock.tsx index e24f4f4..4c53b59 100644 --- a/components/practice-dock.tsx +++ b/components/practice-dock.tsx @@ -2,12 +2,11 @@ import { useCallback, useRef, useState } from "react"; import { ChevronRightIcon } from "lucide-react"; -import { MessageResponse } from "@/components/ai-elements/message"; +import { ToolContentResponse } from "@/components/ai-elements/message"; import { Button } from "@/components/ui/button"; import { joinDictation, useDictationTarget } from "@/components/dictation"; import { useEnterSubmits } from "@/components/enter-submits"; import { cn } from "@/lib/utils"; -import { repairMathForDisplay } from "@/src/core"; /** * The practice dock (practice sessions, owner design 2026-10-04): an @@ -109,7 +108,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
Problem
- {typeof input.statement === "string" ? repairMathForDisplay(input.statement) : input.statement} + {input.statement}
diff --git a/components/question-dock.tsx b/components/question-dock.tsx index 3b6f848..e0a94d7 100644 --- a/components/question-dock.tsx +++ b/components/question-dock.tsx @@ -5,10 +5,11 @@ import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { finishDictation, isDictationActive, joinDictation, useDictationTarget } from "@/components/dictation"; import { useEnterSubmits } from "@/components/enter-submits"; -import { MessageResponse } from "@/components/ai-elements/message"; +import { ToolContentResponse } from "@/components/ai-elements/message"; import { cn } from "@/lib/utils"; import { buildAnswer, + repairMathForDisplay, normalizeQuizType, CONFIDENCE_STOPS, DEFAULT_CONFIDENCE_INDEX, @@ -166,7 +167,7 @@ export function QuestionDock({ /** A plain-text teaser of a question for the collapsed bar: unwrap math, flatten common LaTeX. */ function teaserText(q: string): string { - return q + return repairMathForDisplay(q) .replace(/\\\(([\s\S]*?)\\\)/g, "$1") // \(...\) inline math .replace(/\\\[([\s\S]*?)\\\]/g, "$1") // \[...\] display math .replace(/\$\$?([^$]*)\$\$?/g, "$1") // standard $...$/$$...$$ math @@ -560,11 +561,11 @@ function QuestionDockForm({ {qd.mode === "multi-select" && " · select all that apply"}
- {qd.question} + {qd.question}
{qd.context && (
- {qd.context} + {qd.context}
)} @@ -600,7 +601,7 @@ function QuestionDockForm({ )} - {opt.label} + {opt.label} {kind !== "quiz" && opt.description && ( // Option subtext renders in Source Sans 3 (owner's mockup); // the "I don't know" copy stays JetBrains Mono. QUIZ options render @@ -609,9 +610,9 @@ function QuestionDockForm({ // records must not leak either. Same MessageResponse path as the // label (owner, 2026-10-12): descriptions are full markdown — // LaTeX included — not plain text. - + {opt.description} - + )} diff --git a/docs/tool-content-recovery.md b/docs/tool-content-recovery.md new file mode 100644 index 0000000..8151b1a --- /dev/null +++ b/docs/tool-content-recovery.md @@ -0,0 +1,63 @@ +# Tool-content presentation recovery + +## Contract + +Model-authored tool content is parsed against its schema before presentation. Math or document recovery must not change persisted tool arguments, option identities, learner answers, shuffling, grading, or future answer snapshots. Those use the source-derived, schema-parsed content. + +`ToolContentResponse` projects text through `repairMathForDisplay`, then uses the ordinary Markdown renderer. Teaching/probing questions, asks, recalls/explanations, and practice statements/solutions share this path. Ordinary assistant prose does not receive tool-content recovery. + +There are three separate responsibilities: + +1. **Argument/shape parsing:** decode encoded argument shapes, then schema conversion, validation and cleaning. No LaTeX recovery here. +2. **Content projection:** distinguish literal code, prose/document structure and math; recover only supported escaping defects. +3. **Presentation:** Markdown/KaTeX rendering, including paragraph layout. In labeled Why/answer reveals, only the first direct paragraph is inline with its label; subsequent paragraphs retain their normal layout. + +## Recovery rules + +### Literal code + +Code and inline-code source ranges come from the rendering stack's CommonMark parser with its math extensions. This covers fences, indentation, quotes and lists without maintaining separate container heuristics. The projection copies those source ranges verbatim; it does not serialize an AST back into Markdown. + +### Document escapes + +A literal two-character `\n` is different from a physical newline. Document-layout recovery requires evidence such as repeated document-escape candidates or a candidate alongside over-escaped known TeX commands. Complete known n-prefixed TeX words, including `\nu`, `\nabla`, `\neq` and declared n-prefixed macros, take precedence over interpreting their prefix as document layout. Code, genuine TeX comments and recognized path tokens are protected. + +Recovered double-dollar display framing puts the formula and closing fence on their own lines. Decoding the opening line alone can otherwise turn closing dollars into erroneous TeX content. + +### JSON-swallowed command prefixes + +Inside math, a control character can be the result of JSON consuming the start of a command: TAB + `ext` instead of `\text`, for example. Recovery reconstructs a command only if the control character plus the **complete following alphabetic word** names a known KaTeX command. All supported occurrences recover in one projection. + +The owner-approved typical-model-authoring policy is: + +- **Inline math:** recover recognized prefixes, including physical LF + `u` to `\nu`, and TAB + `o` to `\to`. There is no word-length cutoff. +- **Display math:** keep physical LF and CRLF as source formatting; recover recognized non-newline prefixes. +- **Correctly written commands:** remain commands in both modes. `\nu` is never decoded into LF + `u`. +- **Unrecognized whitespace:** remains unchanged. `x+y` does not become invented `\t`/`\ty` commands. + +The control-character case is inherently ambiguous: whitespace followed by a variable may happen to spell a command. This policy targets typical model-authored LaTeX, not every syntactically possible human whitespace arrangement. TeX parse success alone cannot distinguish the two meanings. + +### Extra command escaping, percentages and rows + +Recovery examines the original TeX context before changing backslash runs: + +- Complete native environment pairs and native `\substack` arguments preserve genuine row separators. +- Consistently extra-escaped environment pairs establish an escaping factor for commands **and rows**. For example, an encoded four-backslash row becomes two backslashes, not one. +- Outside protected native row/comment contexts, extra prefixes on known commands can recover. Unknown command words are not invented. +- Doubled percent escapes in ordinary numeric expressions can recover to `\%`, including numeric percentages at the end of a display formula. Genuine following comment text or equation rows remain literal. This is not a blanket `%` addition to an even-backslash rule. +- As an additional guard, partial command recovery does not turn parseable source into a TeX parse failure. Parse success is not the primary correctness test: both a corrupted matrix and a lost percentage can parse successfully. + +Unbalanced, unsupported or ambiguous content is not generally corrected into new mathematics. This is an escaping recovery projection, not a general TeX authoring repair system. There are no language-specific decoders. + +## Historical answers + +`answerForPresentation` joins model-authored display fields to the original tool call without changing recorded answer facts. Option labels bind only through unambiguous selected values, exact/source-recovered labels, or one uniquely remaining pair in a complete bijection. Ambiguous rows keep their saved snapshots. Saved order, indices, selected values, verdicts, confidence and learner text are retained. Dismissed answers are unchanged. No migration or record rewrite is performed. + +## Verification + +- `tests/content-recovery.test.ts`: recovery/preservation rules and adversarial row/comment/code examples. +- `tests/tool-content-presentation.test.tsx`: provenance-labeled synthetic corpus regressions, historical joins and ambiguity. +- `tests/tool-content-interaction.test.tsx`: select/submit/persist/reveal flow and paragraph structure. +- Existing Markdown, card, practice, tool-shape and grading suites remain applicable. + +The local frozen corpus can be replayed offline with `scripts/replay-tool-rendering-corpus.ts`. Replay verifies source hashes and preserves baseline findings; it reports glyph loss, matrix rows, rendering errors, code bytes, schema regressions, answer facts and projection convergence. Corpus collection and larger diagnostic batches are separate from product recovery and are not prerequisites for running these unit tests. diff --git a/package-lock.json b/package-lock.json index 7a91ffc..21e2398 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,10 @@ "katex": "0.18.2", "linkedom": "^0.18.13", "lucide-react": "^1.33.0", + "mdast-util-from-markdown": "2.0.3", + "mdast-util-math": "3.0.0", "media-chrome": "^4.19.2", + "micromark-extension-math": "3.1.0", "mermaid": "^11.17.0", "motion": "^13.1.1", "nanoid": "^6.0.1", diff --git a/package.json b/package.json index 1b0c649..fefaa8c 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,10 @@ "katex": "0.18.2", "linkedom": "^0.18.13", "lucide-react": "^1.33.0", + "mdast-util-from-markdown": "2.0.3", + "mdast-util-math": "3.0.0", "media-chrome": "^4.19.2", + "micromark-extension-math": "3.1.0", "mermaid": "^11.17.0", "motion": "^13.1.1", "nanoid": "^6.0.1", diff --git a/src/core/answer-presentation.ts b/src/core/answer-presentation.ts new file mode 100644 index 0000000..ffc61ef --- /dev/null +++ b/src/core/answer-presentation.ts @@ -0,0 +1,57 @@ +/** Read-only provenance for historical question cards. Never persist this + * projection: answer identities, ordering, grading, and learner text remain + * the recorded facts; only model-authored display text comes from the call. */ +import { normalizeOptions, type QuestionAnswerData } from "./question-core.ts"; +import { parseAskInput, parseExplainInput, parseQuizInput } from "./tool-parts.ts"; +import { repairMathForDisplay } from "./math-delims.ts"; + +export function answerForPresentation(answer: QuestionAnswerData, input: unknown): QuestionAnswerData { + if (answer.dismissed) return answer; + const quiz = answer.kind === "quiz" ? parseQuizInput(input) : undefined; + const explain = answer.kind === "explain" ? parseExplainInput(input) : undefined; + const source = quiz ?? explain ?? (answer.kind === "ask" ? parseAskInput(input) : undefined); + if (!source) return answer; + const projected = { ...answer, questionText: source.question }; + if (quiz) projected.explanation = quiz.explanation; + if (explain) projected.committed = explain.correctAnswer; + if (!answer.options?.length || !("options" in source) || !source.options?.length) return projected; + + const original = normalizeOptions(source.options); + const saved = answer.options; + const bindings = new Map(); // saved position -> original position + const used = new Set(); + const bindUnique = (candidates: number[][]) => { + for (let si = 0; si < saved.length; si++) { + if (bindings.has(si)) continue; + const possible = candidates[si]!.filter((oi) => !used.has(oi)); + if (possible.length !== 1) continue; + const oi = possible[0]!; + // Two saved rows claiming the same source are ambiguous, even if + // each row independently has just one candidate. + if (candidates.some((other, sj) => sj !== si && !bindings.has(sj) && other.includes(oi))) continue; + bindings.set(si, oi); + used.add(oi); + } + }; + // Selected-answer values are stable identities when the record retained + // them. Do not normalize those values, recompute a shuffle, or regrade. + bindUnique(saved.map((option) => { + const values = answer.answers.filter((a) => a.index === option.index).map((a) => a.value); + return original.flatMap((o, oi) => values.includes(o.value) ? [oi] : []); + })); + const recoveredLabels = original.map((o) => repairMathForDisplay(o.label)); + bindUnique(saved.map((option) => original.flatMap((o, oi) => + o.label === option.label || recoveredLabels[oi] === option.label ? [oi] : []))); + // A bijection with one remaining pair has no ordering ambiguity. With + // multiple unmatched rows, keep their snapshots rather than guess. + if (saved.length === original.length && bindings.size === saved.length - 1) { + const si = saved.findIndex((_, i) => !bindings.has(i)); + const oi = original.findIndex((_, i) => !used.has(i)); + bindings.set(si, oi); + } + projected.options = saved.map((option, si) => { + const oi = bindings.get(si); + return oi === undefined ? option : { ...option, label: original[oi]!.label }; + }); + return projected; +} diff --git a/src/core/content-reader.ts b/src/core/content-reader.ts new file mode 100644 index 0000000..a774094 --- /dev/null +++ b/src/core/content-reader.ts @@ -0,0 +1,215 @@ +/** Source-preserving boundaries for tool-authored Markdown. Recovery never + * mutates input, reserializes Markdown, or enters protected literal content. */ +import katex from "katex"; +import { fromMarkdown } from "mdast-util-from-markdown"; +import { mathFromMarkdown } from "mdast-util-math"; +import { math } from "micromark-extension-math"; + +export type ContentSpan = + | { kind: "prose" | "code"; raw: string } + | { kind: "math"; raw: string; open: string; body: string; close: string; display: boolean }; +const blankLine = /^\r?\n[ \t]*\r?\n/; +const escaped = (text: string, i: number) => { + let n = 0; while (i > 0 && text[--i] === "\\") n++; + return n % 2 === 1; +}; +const runLength = (text: string, i: number, char: string) => { let n = 0; while (text[i + n] === char) n++; return n; }; +type MarkdownNode = { type: string; position?: { start: { offset?: number }; end: { offset?: number } }; children?: MarkdownNode[] }; +/** Use the rendering stack's actual CommonMark+math grammar for literal + * ranges, including nested quotes/lists and implicit container endings. + * Keep source offsets: never serialize the AST back into Markdown. */ +function literalRanges(text: string): Array<{ start: number; end: number }> { + const pending: MarkdownNode[] = [fromMarkdown(text, { extensions: [math({ singleDollarTextMath: true })], mdastExtensions: [mathFromMarkdown()] })]; + const ranges: Array<{ start: number; end: number }> = []; + while (pending.length) { + const node = pending.pop()!; + if (node.type === "code" || node.type === "inlineCode") { + const start = node.position?.start.offset, end = node.position?.end.offset; + if (start !== undefined && end !== undefined) ranges.push({ start, end }); + } else if (node.children) pending.push(...node.children); + } + return ranges.sort((a, b) => a.start - b.start); +} + +/** Mirrors the app's native dollar-run and legacy-delimiter conventions, + * with code and blank paragraphs as inline boundaries. */ +export function readContentSpans(text: string): ContentSpan[] { + const spans: ContentSpan[] = []; + const literals = literalRanges(text); + let literal = 0, i = 0, plain = 0; + const opaque = (end: number) => { + if (plain < i) spans.push({ kind: "prose", raw: text.slice(plain, i) }); + spans.push({ kind: "code", raw: text.slice(i, end) }); i = plain = end; + }; + while (i < text.length) { + while (literal < literals.length && literals[literal]!.end <= i) literal++; + const barrier = literals[literal]?.start ?? Infinity; + if (i >= barrier) { opaque(literals[literal]!.end); continue; } + let open = "", close = "", display = false, after = -1, at = -1; + if (text[i] === "\\" && !escaped(text, i) && ["(", "["].includes(text[i + 1] ?? "")) { + open = text.slice(i, i + 2); close = open === "\\(" ? "\\)" : "\\]"; display = open === "\\["; + for (let j = i + 2; j < text.length - 1; j++) { + if (j >= barrier || (!display && blankLine.test(text.slice(j)))) break; + if (text.startsWith(close, j) && !escaped(text, j)) { at = j; after = j + 2; break; } + } + } else if (text[i] === "$" && !escaped(text, i)) { + const size = runLength(text, i, "$"); open = "$".repeat(size); + const eol = text.indexOf("\n", i + size); + const before = text.slice(text.lastIndexOf("\n", i - 1) + 1, i); + display = size >= 2 && /^[ \t]{0,3}$/.test(before) && !text.slice(i + size, eol < 0 ? text.length : eol).includes("$"); + if (display) { + let start = eol; + while (start >= 0 && start < text.length) { + start++; + const next = text.indexOf("\n", start); + const match = /^([ \t]{0,3})(\$+)[ \t]*\r?$/.exec(text.slice(start, next < 0 ? text.length : next)); + if (match && match[2]!.length >= size) { at = start + match[1]!.length; close = match[2]!; after = at + close.length; break; } + start = next; + } + } else { + for (let j = i + size; j < text.length;) { + if (j >= barrier || blankLine.test(text.slice(j))) break; + if (text[j] === "$" && !escaped(text, j)) { + const n = runLength(text, j, "$"); + if (n === size) { at = j; close = open; after = j + n; break; } + j += n; + } else j++; + } + } + } + if (at >= 0) { + if (plain < i) spans.push({ kind: "prose", raw: text.slice(plain, i) }); + spans.push({ kind: "math", raw: text.slice(i, after), open, body: text.slice(i + open.length, at), close, display }); + i = plain = after; + } else i += open.length || 1; + } + if (plain < text.length) spans.push({ kind: "prose", raw: text.slice(plain) }); + return spans; +} + +export function mapMathContent(text: string, transform: (body: string, display: boolean) => string): string { + return readContentSpans(text).map(span => span.kind === "math" ? span.open + transform(span.body, span.display) + span.close : span.raw).join(""); +} + +const knownCommands = new Map(); +/** Probe only a command name, never arbitrary content. Missing-argument errors + * still identify a known command. No trust, macros, IO, or persistent TeX state. */ +export function knownTexCommand(name: string): boolean { + if (!/^[A-Za-z]+$/.test(name)) return false; + const cached = knownCommands.get(name); if (cached !== undefined) return cached; + let known = true; + try { katex.renderToString("\\" + name, { throwOnError: true, trust: false, strict: false }); } + catch (error) { known = !String(error).includes("Undefined control sequence: \\" + name); } + knownCommands.set(name, known); + return known; +} + +export function canParseTex(body: string, display: boolean): boolean { + try { katex.renderToString(body, { throwOnError: true, trust: false, strict: false, displayMode: display }); return true; } + catch { return false; } +} + +export interface TexRange { start: number; end: number; kind: "row" | "encoded" | "comment"; factor?: number } +export function numericPercentEscape(inner: string, percent: number): boolean { + let start = percent; while (start > 0 && inner[start - 1] === "\\") start--; + if (percent - start < 2 || (percent - start) % 2 !== 0 || !/\d[ \t]*$/.test(inner.slice(0, start))) return false; + const tail = inner.slice(percent + 1); + // A final source-formatting LF before the closing fence is not a next + // equation row. A meaningful following line or comment text remains literal. + if (/^[\s.)\]}]*$/.test(tail)) return true; + return !/[\r\n]/.test(tail) && !/^[ \t]*[A-Za-z]/.test(tail); +} + +/** TeX boundaries shared by document decoding and command recovery. Native + * rows are literal; consistently extra-escaped environment pairs supply an + * explicit escaping factor for both commands AND row separators. */ +export function texRanges(inner: string): TexRange[] { + const ranges: TexRange[] = []; + const stack: Array<{ name: string; start: number; factor: number }> = []; + let i = 0; + while (i < inner.length) { + if (inner[i] === "%" && !escaped(inner, i)) { + let start = i; while (start > 0 && inner[start - 1] === "\\") start--; + const factor = stack.at(-1)?.factor ?? ranges.filter(r => r.kind === "encoded" && i >= r.start && i < r.end).at(-1)?.factor; + const encodedLiteral = factor && factor > 1 && (i - start) % factor === 0 && ((i - start) / factor) % 2 === 1; + const native = stack.some(frame => frame.factor === 1) || ranges.some(r => r.kind === "row" && i >= r.start && i < r.end); + if (encodedLiteral || (!native && numericPercentEscape(inner, i))) { i++; continue; } + const newline = inner.indexOf("\n", i); + const end = newline < 0 ? inner.length : newline + 1; + ranges.push({ start: i, end, kind: "comment" }); i = end; continue; + } + if (inner[i] !== "\\") { i++; continue; } + let end = i + 1; while (inner[end] === "\\") end++; + const count = end - i; + const marker = /^(begin|end)\s*\{([^{}]+)\}/.exec(inner.slice(end)); + if (marker) { + const factor = count % 2 === 1 ? 1 : count; + if (marker[1] === "begin") stack.push({ name: marker[2]!, start: i, factor }); + else if (stack.at(-1)?.name === marker[2] && stack.at(-1)?.factor === factor) { + const open = stack.pop()!; + ranges.push({ start: open.start, end: end + marker[0].length, kind: factor === 1 ? "row" : "encoded", factor }); + } + i = end + marker[0].length; continue; + } + // KaTeX's substack macro expands its braced argument into subarray: + // these are row separators even without literal begin/end markers. + const substack = /^substack\s*\{/.exec(inner.slice(end)); + if (substack) { + let depth = 1, j = end + substack[0].length; + for (; j < inner.length && depth > 0; j++) { + if (inner[j] === "%" && !escaped(inner, j)) { const lf = inner.indexOf("\n", j); j = lf < 0 ? inner.length : lf; continue; } + if (!escaped(inner, j)) { if (inner[j] === "{") depth++; else if (inner[j] === "}") depth--; } + } + if (depth === 0) ranges.push({ start: i, end: j, kind: count % 2 === 1 ? "row" : "encoded", factor: count % 2 === 1 ? 1 : count }); + } + i = end; + } + return ranges.sort((a, b) => a.start - b.start); +} + +export function outsideTexComments(body: string, transform: (part: string) => string): string { + let from = 0; const out: string[] = []; + for (const range of texRanges(body).filter(r => r.kind === "comment")) { + out.push(transform(body.slice(from, range.start)), body.slice(range.start, range.end)); from = range.end; + } + out.push(transform(body.slice(from))); return out.join(""); +} + +/** Document escapes are not arbitrary string decoding. Complete known TeX + * words win over the prefix `\\n`; code is never inspected or rewritten. */ +export function recoverDocumentLayout(text: string): string { + const spans = readContentSpans(text); + const candidates = spans.filter(span => span.kind !== "code").map(span => span.raw).join("\u0000"); + const declared = new Set([ + ...candidates.matchAll(/\\(?:newcommand|renewcommand|providecommand)\*?\s*(?:\{\s*)?\\(n[A-Za-z]+)/g), + ...candidates.matchAll(/\\(?:def|gdef|edef)\s*\\(n[A-Za-z]+)/g), + ].map(m => m[1]!)); + const knownN = (name: string) => declared.has(name) || knownTexCommand(name); + const tokens = [...candidates.matchAll(/(\\+)(n[A-Za-z]*)/g)]; + const evidence = tokens.filter(m => m[1]!.length % 2 === 1 && !knownN(m[2]!)); + const structural = evidence.length >= 2 || candidates.includes("\\n\\n") || (evidence.length > 0 && + [...candidates.matchAll(/(\\+)([A-Za-z]+)/g)].some(m => m[1]!.length % 2 === 0 && knownTexCommand(m[2]!))); + if (!structural) return text; + const decode = (part: string) => part.replace(/(\\+)(n[A-Za-z]*)/g, (whole: string, slashes: string, name: string, offset: number) => { + if (slashes.length % 2 === 0 || knownN(name)) return whole; + const word = part.slice(0, offset).split(/\s/).at(-1) ?? ""; + if (/^(?:[A-Za-z]:[\\/]|https?:\/\/|\/)/.test(word + whole)) return whole; + return slashes.slice(0, -1) + "\n" + name.slice(1); + }); + const projected = spans.map(span => ({ span, body: span.kind === "math" ? outsideTexComments(span.body, decode) : span.kind === "code" ? span.raw : decode(span.raw) })); + let out = ""; + for (let i = 0; i < projected.length; i++) { + const { span, body } = projected[i]!; + if (span.kind !== "math") { out += body; continue; } + // Decoding document layout can expose a flow opener whose close was + // written on the formula line. Frame BOTH ends; otherwise Markdown + // consumes the closing dollars as TeX and introduces a rendering error. + if (/^\${2,}$/.test(span.open) && body !== span.body && body.includes("\n")) { + if (!/^[ \t]{0,3}$/.test(out.slice(out.lastIndexOf("\n") + 1))) out += "\n\n"; + out += span.open + (body.startsWith("\n") ? "" : "\n") + body + (body.endsWith("\n") ? "" : "\n") + span.close; + const next = projected[i + 1]; + if (next && !/^[\r\n]/.test(next.span.kind === "math" ? next.span.open : next.body)) out += "\n\n"; + } else out += span.open + body + span.close; + } + return out; +} diff --git a/src/core/index.ts b/src/core/index.ts index b4106d7..77de4ed 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -47,6 +47,7 @@ export * from "./input-events.ts"; // the deleted server projection to the client side. Pure, browser-safe. export * from "./view.ts"; export * from "./tool-parts.ts"; +export * from "./answer-presentation.ts"; export * from "./prices.ts"; export * from "./context-usage.ts"; export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts"; diff --git a/src/core/math-delims.ts b/src/core/math-delims.ts index b939054..21d7bed 100644 --- a/src/core/math-delims.ts +++ b/src/core/math-delims.ts @@ -1,572 +1,121 @@ -/** - * Legacy math delimiter preprocessing — the \(...\)/\[...\] reader. - * - * Current authoring uses `$...$` inline and `$$...$$` on separate lines for - * display (owner, 2026-10-12). This pure transform keeps correctly formed - * old backslash-delimited content readable; dollar math passes through to - * Streamdown's native parser. Escape restoration below recognizes both - * syntaxes while retaining the existing recovery rules. - * - * Rules: - * - `\(...\)` → `$$...$$` (two-dollar inline, still accepted by micromark), - * `\[...\]` → `$$\n...\n$$` (block form: flow math). - * - An escaped delimiter (`\\(` = literal backslash + paren) is left alone. - * - Nothing inside fenced code blocks or inline code spans is touched. - * - Unbalanced delimiters are left untouched: Streamdown streams chunks, and - * an incomplete `\[` completes on a later chunk (the whole text is - * re-transformed each render). - * - * Pure string → string. No markdown parsing — a single linear scan with a - * code-state machine, cheap enough to run in the render path of a memo'd - * component. - */ +/** Shared Markdown/TeX boundaries for legacy delimiter compatibility and + * display-only tool-content recovery. Source arguments are never rewritten. */ +import { + canParseTex, knownTexCommand, mapMathContent, numericPercentEscape, outsideTexComments, + readContentSpans, recoverDocumentLayout, texRanges, +} from "./content-reader.ts"; -const INLINE_OPEN = "\\("; -const INLINE_CLOSE = "\\)"; -const DISPLAY_OPEN = "\\["; -const DISPLAY_CLOSE = "\\]"; - -const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})/; - -/** - * Is the backslash at `i` itself escaped (i.e. preceded by an odd number of - * backslashes)? If so, the `\(`/`\[` starting there is a literal backslash - * followed by a bracket — not a delimiter. - */ -function isEscaped(text: string, i: number): boolean { - let count = 0; - for (let j = i - 1; j >= 0 && text[j] === "\\"; j--) count += 1; - return count % 2 === 1; -} - -/** Length of the backtick run starting at `i` (0 if `text[i]` is not a tick). */ -function backtickRun(text: string, i: number): number { - let count = 0; - while (text[i + count] === "`") count += 1; - return count; -} - -/** - * Try to find the closing delimiter for the opening one at `i` (2 chars, - * already verified unescaped). The close scan stops at inline code spans and - * fence starts (barriers — math never contains them), and returns -1 when the - * delimiter is unbalanced (mid-stream chunk). - */ -function findClose( - text: string, - from: number, - close: string, -): number { - for (let j = from; j < text.length - 1; j++) { - if (text[j] === "`") return -1; // inline code barrier - if ( - text[j] === "\\" && - text[j + 1] === close[1] && - !isEscaped(text, j) - ) { - return j; - } - } - return -1; -} - -/** True when `i` sits at the start of a line (position 0 or right after \n). */ -function atLineStart(text: string, i: number): boolean { - return i === 0 || text[i - 1] === "\n"; -} - -/** - * Copy a fenced code block starting at `i` (line start, fence regex matched) - * verbatim to the output. Returns the index after the closing fence line — - * or the end of the text when the fence is unclosed (mid-stream: everything - * after is literal code until the chunk completes). - */ -function copyFencedBlock(text: string, i: number, out: string[]): number { - const open = FENCE_OPEN_RE.exec(text.slice(i)); - const marker = open?.[1] ?? "```"; - const closeRe = new RegExp(`^ {0,3}\\${marker[0]}{${marker.length},}[ \\t]*$`); - let j = i; - // Copy lines until the matching closing fence. - while (j < text.length) { - const lineEnd = text.indexOf("\n", j); - const next = lineEnd === -1 ? text.length : lineEnd + 1; - const line = text.slice(j, lineEnd === -1 ? text.length : lineEnd); - out.push(text.slice(j, next)); - if (j > i && closeRe.test(line)) return next; // closing fence line - j = next; - } - return text.length; -} - -/** Is the `\[` at `i` block-positioned (own line, only whitespace before)? */ -function displayIsBlockPositioned(text: string, i: number): boolean { - for (let j = i - 1; j >= 0; j--) { - const ch = text[j]; - if (ch === "\n") return true; - if (ch !== " " && ch !== "\t") return false; - } - return true; -} - -/** Is the `\[` at `i` indented (whitespace before it on the line)? */ -function displayIsIndented(text: string, i: number): boolean { - let sawWhitespace = false; - for (let j = i - 1; j >= 0; j--) { - const ch = text[j]; - if (ch === "\n") return sawWhitespace; - if (ch !== " " && ch !== "\t") return false; // mid-paragraph - sawWhitespace = true; - } - return sawWhitespace; -} - -/** Does the closing delimiter end its line (only \n or EOF after it)? */ -function closeEndsLine(text: string, afterClose: number): boolean { - return afterClose >= text.length || text[afterClose] === "\n"; -} - -/** - * Rewrite balanced `\(...\)` → `$$...$$` and `\[...\]` → `$$\n...\n$$`. - * Everything else passes through byte-for-byte. - */ -/** The escaped-dollar-aware inline closer (owner, 2026-10-12): micromark's - * mathText closer is code-span logic — ANY size-matched `$` run closes, - * `\$` included, so `$x = \$5$` closes at the `\$` and errors. KaTeX's - * own auto-render scanner skips escaped dollars, and KaTeX renders `\$` - * fine — the author's intent is unambiguous: an escaped dollar (odd number - * of backslashes before it) never closes the span. Mirrors findDollarClose's - * inline rules otherwise (exact size match, whole-run skip). */ -function findInlineDollarCloseEscapeAware(text: string, from: number): number { - for (let j = from; j < text.length;) { - if (text[j] !== "$") { - j += 1; - continue; - } - if (isEscaped(text, j)) { - j += 1; - continue; // `\$` never closes - } - const count = dollarRun(text, j); - if (count === 1) return j; - j += count; - } - return -1; -} - -/** Replace `\$` (odd-backslash dollars) inside a math span with the - * dollar-free `\text{\textdollar}` — KaTeX-safe in math mode (bare - * `\textdollar` is text-mode-only there). Even-backslash dollars (`\\$`, - * row break + dollar) are left alone. */ -function replaceEscapedDollars(content: string): string { - let replaced = ""; - for (let j = 0; j < content.length;) { - if (content[j] === "\\" && content[j + 1] === "$" && !isEscaped(content, j)) { - replaced += "\\text{\\textdollar}"; - j += 2; - } else { - replaced += content[j]; - j += 1; - } - } - return replaced; -} - -export function transformMathDelimiters(markdown: string): string { - const out: string[] = []; - let i = 0; - - while (i < markdown.length) { - const ch = markdown[i]!; - - // Fenced code blocks: copied verbatim (only detectable at line start). - if (atLineStart(markdown, i) && FENCE_OPEN_RE.test(markdown.slice(i))) { - i = copyFencedBlock(markdown, i, out); - continue; - } - - // Inline code spans: copied verbatim. - if (ch === "`") { - const run = backtickRun(markdown, i); - let j = i + run; - // Find the matching run (same length, per CommonMark). - while (j < markdown.length) { - const nextRun = backtickRun(markdown, j); - if (nextRun === run) break; - j += Math.max(nextRun, 1); - } - if (j < markdown.length) { - out.push(markdown.slice(i, j + run)); - i = j + run; - } else { - // Unclosed run (mid-stream): literal, keep scanning after it. - out.push(markdown.slice(i, i + run)); - i += run; - } - continue; - } - - // Math delimiters. - if (ch === "\\" && !isEscaped(markdown, i)) { - const next = markdown[i + 1]; - if (next === "(" || next === "[") { - const isDisplay = next === "["; - const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN; - const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE; - const closeAt = findClose(markdown, i + open.length, close); - if (closeAt !== -1) { - const content = markdown.slice(i + open.length, closeAt).trim(); - if (content.length > 0) { - if (isDisplay && displayIsIndented(markdown, i)) { - // Indented display math (inside a list item, quote, …): - // an indented $$ flow block half-parses inside list - // continuations (empty math, eaten items), so degrade to - // the inline form — renders inline, stays inside the item. - // mathText cannot span line endings, so collapse them. - out.push(`$$${content.replace(/\s*\n\s*/g, " ")}$$`); - } else if (isDisplay) { - // Flow math ($$ alone on its lines) renders as display - // KaTeX. Mid-paragraph \[...\] (or trailing text after the - // closer) would half-parse into literal dollars + KaTeX - // errors — pad with blank lines to force a clean block. - // Block-positioned math passes through in place. - const afterClose = closeAt + close.length; - const padBefore = - displayIsBlockPositioned(markdown, i) ? "" : "\n\n"; - const padAfter = - closeEndsLine(markdown, afterClose) ? "" : "\n\n"; - out.push(`${padBefore}$$\n${content}\n$$${padAfter}`); - } else { - // Preserve the legacy reader's two-dollar inline output - // (renders inline, not display). - out.push(`$$${content}$$`); - } - i = closeAt + close.length; - continue; - } - } - // Unbalanced or empty: leave both characters untouched. - } - } - - // Escaped dollars inside inline math (owner, 2026-10-12): normalize - // BEFORE tokenization — inside a balanced inline span, `\$` becomes the - // dollar-free `\text{\textdollar}` so the scanner's closer is the one - // the author meant (micromark would otherwise close at the `\$`, error - // out, and cascade). Display runs are copied whole and left untouched — - // a lone `$` can't close `$$`, so `\$` already survives there — and - // copying the run stops its second `$` posing as an inline opener. - // Spans without escaped dollars emit byte-identical text; prose `\$`, - // code, and unbalanced spans are untouched. - if (ch === "$" && !isEscaped(markdown, i)) { - const run = dollarRun(markdown, i); - if (run === 1) { - const closeAt = findInlineDollarCloseEscapeAware(markdown, i + 1); - if (closeAt !== -1) { - const content = markdown.slice(i + 1, closeAt); - const replaced = replaceEscapedDollars(content); - if (replaced !== content) { - out.push(`$${replaced}$`); - i = closeAt + 1; - continue; - } - } - } else { - out.push(markdown.slice(i, i + run)); - i += run; - continue; - } - } - - out.push(ch); - i += 1; - } - - return out.join(""); -} - -/** - * LaTeX-escape restoration (owner ruling 2026-09-26) — the JSON-debris fix. - * - * When a model emits LaTeX with SINGLE backslashes inside JSON tool-call - * arguments (`\forall`, not the properly escaped `\\forall`), JSON escape - * resolution eats the command's first letter: `\f` → form feed + "orall", - * `\v` → vertical tab + "arphi", `\t` → tab + "o". The debris survives into - * the persisted RAW args and renders as garbage. Inside a MATH SPAN these - * control chars can never be legitimate content, so restoring the two-char - * escape reconstructs the model's intent byte-perfectly. Outside spans the - * same chars might be real prose (pasted tables, formatted text), so only - * balanced math spans are touched. The legacy `\(…\)` / `\[…\]` pass stays - * unchanged; the dollar reader adds `$…$` and fenced `$$…$$` recognition, - * excluding code and escaped opening dollars. Unbalanced spans pass through. - * - * ASYMMETRIC NEWLINE RULE (owner ruling 2026-09-27, Option A): inline spans - * restore all six debris chars — real newlines/tabs inside INLINE math - * essentially never occur (options and inline math are single-line by - * nature). Display spans restore five and KEEP real newlines — multi-line - * display math (aligned environments, equation systems) is common, and a - * real line break is byte-identical on the wire to sloppy `\neq`/`\nu`/ - * `\neg`/`\nabla` (JSON `\n` resolves to a newline either way). Corrupting - * legitimate content is strictly worse than failing to repair that rarest - * slice, so display-span n-command sloppiness stays broken by design. - */ -const ESCAPE_DEBRIS: Record = { - "\b": "\\b", - "\t": "\\t", - "\n": "\\n", - "\v": "\\v", - "\f": "\\f", - "\r": "\\r", +const escaped = (text: string, i: number) => { + let n = 0; while (i > 0 && text[--i] === "\\") n++; + return n % 2 === 1; }; -const DEBRIS_RE = /[\b\t\n\v\f\r]/; - -/** Display spans keep real newlines (multi-line equations) — debris there is - * everything except \n (a lone real \r never occurs in authored text, and - * `\right`/`\rho`/`\rangle` corruption is common in display math). */ -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)); +/** Preserve the legacy list-continuation policy: indented display math + * degrades to inline rather than disrupting list structure. Literal code + * indentation is already protected by the shared reader. */ +function indented(text: string, i: number): boolean { + const prefix = text.slice(text.lastIndexOf("\n", i - 1) + 1, i); + return prefix.length > 0 && /^[ \t]+$/.test(prefix); +} +function blockPosition(text: string, i: number): boolean { + return /^[ \t]*$/.test(text.slice(text.lastIndexOf("\n", i - 1) + 1, i)); } +/** micromark closes native single-dollar spans at escaped dollars too. + * Replace an intended literal dollar with equivalent dollar-free TeX before + * tokenization. Code, prose, unbalanced spans and display dollars stay raw. */ +function replaceEscapedDollars(content: string): string { + let out = ""; + for (let i = 0; i < content.length; i++) { + if (content[i] === "\\" && content[i + 1] === "$" && !escaped(content, i)) { + out += "\\text{\\textdollar}"; i++; + } else out += content[i]; + } + return out; +} + +/** Read old \(...\)/\[...\] syntax without touching literal content or + * reparsing native math as Markdown. Dollar authoring remains native. */ +export function transformMathDelimiters(markdown: string): string { + let offset = 0; + return readContentSpans(markdown).map(span => { + const start = offset; offset += span.raw.length; + if (span.kind !== "math") return span.raw; + if (span.open === "$") return "$" + replaceEscapedDollars(span.body) + span.close; + if (span.open !== "\\(" && span.open !== "\\[") return span.raw; + const body = span.body.trim(); + if (!body) return span.raw; + if (span.open === "\\(") return `$$${body}$$`; + if (indented(markdown, start)) return `$$${body.replace(/\s*\n\s*/g, " ")}$$`; + const before = blockPosition(markdown, start) ? "" : "\n\n"; + const end = start + span.raw.length; + const after = end === markdown.length || markdown[end] === "\n" ? "" : "\n\n"; + return `${before}$$\n${body}\n$$${after}`; + }).join(""); +} + +const DEBRIS: Record = { + "\b": "\\b", "\t": "\\t", "\v": "\\v", "\f": "\\f", "\r": "\\r", +}; + +/** Recover JSON-swallowed command prefixes only in recognized math, never + * in code or genuine TeX comments. Restore all debris in one pass. Physical + * display newlines and CRLF remain source whitespace. Inline LF recovery is + * limited to complete known n-command names. Under the approved typical- + * model-authoring policy this includes newline+u→nu in inline math only; + * already-correct commands are never decoded into whitespace. */ export function restoreMathEscapes(text: string): string { - if (!DEBRIS_RE.test(text)) return text; - return restoreDollarMathEscapes(restoreLegacyMathEscapes(text)); + if (!/[\b\t\n\v\f\r]/.test(text)) return text; + return mapMathContent(text, (body, display) => outsideTexComments(body, part => { + const restored = part.replace(/[\b\t\v\f\r]/g, (char, offset: number) => { + if (char === "\r" && part[offset + 1] === "\n") return char; + const suffix = /^[A-Za-z]+/.exec(part.slice(offset + 1))?.[0]; + return suffix && knownTexCommand(DEBRIS[char]!.slice(1) + suffix) ? DEBRIS[char]! : char; + }); + if (display) return restored; + return restored.replace(/\n([A-Za-z]+)/g, (whole, suffix: string) => + knownTexCommand("n" + suffix) ? "\\n" + suffix : whole); + })); } -/** Keep legacy recovery byte-for-byte; only the recognized syntax expands. */ -function restoreLegacyMathEscapes(text: string): string { - if (!DEBRIS_RE.test(text)) return text; - const out: string[] = []; - let i = 0; - while (i < text.length) { - const ch = text[i]!; - if (ch === "\\" && !isEscaped(text, i)) { - const next = text[i + 1]; - if (next === "(" || next === "[") { - const isDisplay = next === "["; - const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN; - const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE; - const closeAt = findClose(text, i + open.length, close); - if (closeAt !== -1) { - const inner = text.slice(i + open.length, closeAt); - const debris = isDisplay ? DISPLAY_DEBRIS_RE : DEBRIS_RE; - out.push(open + inner.replace(debris, (c) => ESCAPE_DEBRIS[c]!) + close); - i = closeAt + close.length; - continue; - } - } - } - out.push(ch); - i += 1; - } - return out.join(""); -} +const commandSymbol = /[,;:!|{}%]/; -function dollarRun(text: string, i: number): number { - let count = 0; - while (text[i + count] === "$") count += 1; - return count; -} - -/** Native math fences start at a line's block position; one-line $$…$$ - * remains inline. A flow opener may have metadata, but never another $. */ -function dollarIsDisplay(text: string, i: number, size: number): boolean { - if (size < 2 || !displayIsBlockPositioned(text, i)) return false; - const end = text.indexOf("\n", i + size); - return !text.slice(i + size, end === -1 ? text.length : end).includes("$"); -} - -/** Match micromark's delimiter runs: exact size inline, at least the - * opener's size on an otherwise-empty line for display. Inside math, - * backslashes are TeX data, not Markdown escapes for closing dollars. */ -function findDollarClose( - text: string, - from: number, - size: number, - isDisplay: boolean, -): { at: number; after: number } | undefined { - if (isDisplay) { - let lineEnd = text.indexOf("\n", from); - while (lineEnd !== -1) { - const start = lineEnd + 1; - lineEnd = text.indexOf("\n", start); - const line = text.slice(start, lineEnd === -1 ? text.length : lineEnd); - const close = /^([ \t]{0,3})(\$+)[ \t]*\r?$/.exec(line); - if (close && close[2]!.length >= size) { - const at = start + close[1]!.length; - return { at, after: at + close[2]!.length }; - } +/** Decode prefixes and row separators according to ORIGINAL TeX context. + * A native pair is literal; an encoded pair supplies a factor, so an encoded + * four-backslash row becomes TWO, never a command prefix. Unknown commands + * without that structural evidence are left alone. */ +function recoverCommandRuns(inner: string, display: boolean): string { + const ranges = texRanges(inner); + const recovered = inner.replace(/\\+/g, (run: string, offset: number) => { + const contexts = ranges.filter(r => offset >= r.start && offset < r.end); + if (contexts.some(r => r.kind === "row" || r.kind === "comment") || run.length % 2 !== 0) return run; + const next = inner[offset + run.length] ?? ""; + const encoded = contexts.filter(r => r.kind === "encoded").at(-1); + const word = /^[A-Za-z]+/.exec(inner.slice(offset + run.length))?.[0]; + if (encoded) { + const factor = encoded.factor!; + const rows = run.length % (2 * factor) === 0; + const command = word ? knownTexCommand(word) : commandSymbol.test(next); + if (run.length % factor === 0 && (rows || command)) return "\\".repeat(run.length / factor); + return run; } - return undefined; - } - for (let j = from; j < text.length;) { - if (text[j] !== "$") { - j += 1; - continue; - } - const count = dollarRun(text, j); - if (count === size) return { at: j, after: j + count }; - j += count; - } - return undefined; -} - -/** Dollar recognition only — the same six/five-character repair maps, - * with no over-escaping normalization or mathematical validation. */ -function restoreDollarMathEscapes(text: string): string { - if (!DEBRIS_RE.test(text) || !text.includes("$")) return text; - const out: string[] = []; - let i = 0; - while (i < text.length) { - if (atLineStart(text, i) && FENCE_OPEN_RE.test(text.slice(i))) { - i = copyFencedBlock(text, i, out); - continue; - } - if (text[i] === "`") { - const run = backtickRun(text, i); - let j = i + run; - while (j < text.length) { - const nextRun = backtickRun(text, j); - if (nextRun === run) break; - j += Math.max(nextRun, 1); - } - const after = j < text.length ? j + run : i + run; - out.push(text.slice(i, after)); - i = after; - continue; - } - // Legacy spans have already received their own inline/display policy. - // Do not reinterpret dollars inside them in this second pass. - if (text[i] === "\\" && !isEscaped(text, i)) { - const next = text[i + 1]; - if (next === "(" || next === "[") { - const closeAt = findClose(text, i + 2, next === "[" ? DISPLAY_CLOSE : INLINE_CLOSE); - if (closeAt !== -1) { - out.push(text.slice(i, closeAt + 2)); - i = closeAt + 2; - continue; - } - } - } - if (text[i] === "$" && !isEscaped(text, i)) { - const size = dollarRun(text, i); - const isDisplay = dollarIsDisplay(text, i, size); - const close = findDollarClose(text, i + size, size, isDisplay); - if (close) { - const debris = isDisplay ? DISPLAY_DEBRIS_RE : DEBRIS_RE; - out.push( - text.slice(i, i + size) + - text.slice(i + size, close.at).replace(debris, (c) => ESCAPE_DEBRIS[c]!) + - text.slice(close.at, close.after), - ); - i = close.after; - continue; - } - out.push(text.slice(i, i + size)); - i += size; - continue; - } - out.push(text[i]!); - i += 1; - } - return out.join(""); -} - -/** - * Over-escaped command heal (owner ruling 2026-10-15) — the doubled- - * backslash fix. - * - * Some models stochastically emit DOUBLED backslashes inside JSON tool-call - * arguments: the saved record holds `\\nabla` (two real backslashes) where - * `\nabla` was meant. KaTeX reads the `\\` as a row break and then renders - * the command name as plain letters — the learner sees "the word nabla, not - * the symbol". Field evidence (2026-10-15, real data): xiaomi/mimo-v2.6-pro - * doubled per-call, per-field (one probe's question doubled while its - * explanation and options in the SAME call stayed clean — authoring, not - * transport), and deepseek-v4.1-flash once; quadrupling appeared when the - * in-session agent "compensated" for its own doubled first attempt. - * - * THE RULE. Inside a balanced math span ONLY (legacy `\(…\)`/`\[…\]` and - * dollar `$…$`/`$$…$$`, code spans and fences excluded), an EVEN-length - * backslash run (2, 4, …) immediately followed by a command start — a letter - * (any command name; bare letters after a row break are never authored - * content) or a punctuation command (`,` `;` `:` `!` `|` — thin spaces, - * norm bars) — collapses to a single backslash. Even runs before anything - * else (space, newline, `&`, `[`, `$`, digits) are legitimate row breaks and - * stay; ODD runs (3, 5, …) are row break(s) plus a real command with its own - * backslash — e.g. matrix cells written `a \\\nabla` — and stay untouched, - * so matrix content cannot be corrupted. Outside spans nothing is touched: - * doubled backslashes in prose or code are not ours to judge. Unbalanced - * spans pass through, exactly like the debris passes. - */ -const COMMAND_START_CLASS = "[A-Za-z,;:!|]"; - -/** Collapse even backslash runs before a command start; keep odd runs. */ -function halveOverescapedCommandRuns(inner: string): string { - return inner.replace( - new RegExp("(\\\\+)(?=" + COMMAND_START_CLASS + ")", "g"), - (run: string) => (run.length % 2 === 0 ? "\\" : run), - ); + if (next === "%") return numericPercentEscape(inner, offset + run.length) ? "\\" : run; + if (word) return word.length > 1 && knownTexCommand(word) ? "\\" : run; + return next && commandSymbol.test(next) ? "\\" : run; + }); + // Structural evidence is primary (parse success cannot prove row + // preservation). As an additional guard, never turn parseable source into + // a parse failure through partial command recovery. + if (recovered !== inner && !canParseTex(recovered, display) && canParseTex(inner, display)) return inner; + return recovered; } export function healOverescapedCommands(text: string): string { - if (!text.includes("\\\\")) return text; - const out: string[] = []; - let i = 0; - while (i < text.length) { - if (atLineStart(text, i) && FENCE_OPEN_RE.test(text.slice(i))) { - i = copyFencedBlock(text, i, out); - continue; - } - if (text[i] === "`") { - const run = backtickRun(text, i); - let j = i + run; - while (j < text.length) { - const nextRun = backtickRun(text, j); - if (nextRun === run) break; - j += Math.max(nextRun, 1); - } - const after = j < text.length ? j + run : i + run; - out.push(text.slice(i, after)); - i = after; - continue; - } - if (text[i] === "\\" && !isEscaped(text, i)) { - const next = text[i + 1]; - if (next === "(" || next === "[") { - const isDisplay = next === "["; - const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN; - const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE; - const closeAt = findClose(text, i + open.length, close); - if (closeAt !== -1) { - const inner = text.slice(i + open.length, closeAt); - out.push(open + halveOverescapedCommandRuns(inner) + close); - i = closeAt + close.length; - continue; - } - } - } - if (text[i] === "$" && !isEscaped(text, i)) { - const size = dollarRun(text, i); - const isDisplay = dollarIsDisplay(text, i, size); - const close = findDollarClose(text, i + size, size, isDisplay); - if (close) { - out.push( - text.slice(i, i + size) + - halveOverescapedCommandRuns(text.slice(i + size, close.at)) + - text.slice(close.at, close.after), - ); - i = close.after; - continue; - } - out.push(text.slice(i, i + size)); - i += size; - continue; - } - out.push(text[i]!); - i += 1; - } - return out.join(""); + return text.includes("\\\\") ? mapMathContent(text, recoverCommandRuns) : text; +} + +/** Presentation-only, language-independent recovery. Shape parsing, saved + * identities, grading and model history must use the unprojected source. */ +export function repairMathForDisplay(text: string): string { + return healOverescapedCommands(restoreMathEscapes(recoverDocumentLayout(text))); } diff --git a/src/core/tool-parts.ts b/src/core/tool-parts.ts index cb5585d..db4305c 100644 --- a/src/core/tool-parts.ts +++ b/src/core/tool-parts.ts @@ -30,7 +30,6 @@ import { Value } from "typebox/value"; import type { TSchema } from "typebox"; import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts"; -import { repairMathForDisplay } from "./math-delims.ts"; import { healEncodedArguments } from "./argument-heal.ts"; import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts"; @@ -67,28 +66,9 @@ function boundaryParse(schema: TSchema, input: unknown): T | undefined { * never have been posed (e.g. `correctAnswer: undefined`, which Convert * widens to "") renders as a quiet fallback, not a broken quiz. */ -/** LaTeX-escape restoration, deep over every string field (the JSON-debris - * fix, owner ruling 2026-09-26): records persist RAW call args, so - * single-backslash LaTeX eaten by JSON escape resolution (FF ate the 'f' of - * \forall, VT the 'v' of \varphi, TAB the 't' of \to) survives to the - * display path. This is the ONE seam every rendered question passes - * through — new turns and already-poisoned records alike heal here. - * healOverescapedCommands (owner ruling 2026-10-15) rides the same walk: - * the same models also stochastically DOUBLE command backslashes - * (`\\nabla` saved where `\nabla` was meant — KaTeX shows the row break - * plus "nabla" as letters), so even runs before a command start inside - * balanced math spans collapse to one backslash. */ -function restoreMathEscapesDeep(value: unknown): unknown { - if (typeof value === "string") return repairMathForDisplay(value); - if (Array.isArray(value)) return value.map(restoreMathEscapesDeep); - if (typeof value === "object" && value !== null) { - const out: Record = {}; - for (const [k, v] of Object.entries(value)) out[k] = restoreMathEscapesDeep(v); - return out; - } - return value; -} - +/** Shape parsing never repairs authored text. The dock builds its answer + * data from this source copy; recovery belongs exclusively to presentation + * (ToolContentResponse), never option identities, grading, or snapshots. */ export function parseQuizInput(input: unknown): QuizToolInput | undefined { // quizDisplayInputSchema (question-core, owner 2026-10-03): base + the // OPTIONAL `type` — new teach-phase records carry predict|check and the @@ -100,7 +80,7 @@ export function parseQuizInput(input: unknown): QuizToolInput | undefined { // wobble must heal HERE too or a posed quiz renders invisible (the // tool-parts law). healEncodedArguments is the same pure pass the // boundary's withArgumentHeal runs. - const healedInput = healEncodedArguments(restoreMathEscapesDeep(input), quizDisplayInputSchema); + const healedInput = healEncodedArguments(input, quizDisplayInputSchema); const cleaned = boundaryParse(quizDisplayInputSchema, healedInput); if (!cleaned) return undefined; if (quizInputProblems(cleaned).length > 0) return undefined; @@ -114,7 +94,7 @@ export function parseQuizInput(input: unknown): QuizToolInput | undefined { * keying, so they parse to undefined exactly like the zod seam did). */ export function parseAskInput(input: unknown): AskToolInput | undefined { - const cleaned = boundaryParse(askInputSchema, restoreMathEscapesDeep(input)); + const cleaned = boundaryParse(askInputSchema, input); if (!cleaned) return undefined; if (askOptionProblems(cleaned.options ?? []).length > 0) return undefined; return cleaned; @@ -127,7 +107,7 @@ export function parseAskInput(input: unknown): AskToolInput | undefined { * variants share the same fields; the teacher's nodeId is an unknown * key Value.Clean strips, same as the quiz seam. */ export function parseExplainInput(input: unknown): ExplainToolInput | undefined { - const cleaned = boundaryParse(explainDisplayInputSchema, restoreMathEscapesDeep(input)); + const cleaned = boundaryParse(explainDisplayInputSchema, input); if (!cleaned) return undefined; if (explainInputProblems(cleaned).length > 0) return undefined; return cleaned; diff --git a/tests/content-recovery.test.ts b/tests/content-recovery.test.ts new file mode 100644 index 0000000..d8f9196 --- /dev/null +++ b/tests/content-recovery.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { repairMathForDisplay, restoreMathEscapes, transformMathDelimiters } from "../src/core/math-delims.ts"; + +const math = String.raw; +test("document paragraphs and display framing recover without interpreting n-commands as newlines", () => { + const source = math`First.\n\nSecond: $\nabla f\neq0$.\n\n$$\n\frac{1}{2}\n$$`; + assert.equal(repairMathForDisplay(source), "First.\n\nSecond: " + math`$\nabla f\neq0$.` + "\n\n$$\n" + math`\frac{1}{2}` + "\n$$"); +}); +test("literal document escapes fused to unknown math commands use document context", () => { + const source = math`Jacobian:\n\n$$\nJ=\begin{pmatrix}1&2\\3&4\end{pmatrix}\n$$`; + assert.equal(repairMathForDisplay(source), "Jacobian:\n\n$$\n" + math`J=\begin{pmatrix}1&2\\3&4\end{pmatrix}` + "\n$$"); +}); +test("encoded environment profiles decode row separators by their factor, not to one slash", () => { + for (const [source, expected] of [ + [math`$\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$`, math`$\begin{pmatrix}a&b\\c&d\end{pmatrix}$`], + [math`$\\begin{pmatrix}1&2\\\\3&4\\end{pmatrix}$`, math`$\begin{pmatrix}1&2\\3&4\end{pmatrix}$`], + [math`$\\begin{pmatrix}a&b\\\\\\nabla f&d\\end{pmatrix}$`, math`$\begin{pmatrix}a&b\\\nabla f&d\end{pmatrix}$`], + [math`$\\substack{a\\\\beta}$`, math`$\substack{a\\beta}$`], + ] as const) { + assert.equal(repairMathForDisplay(source), expected); + assert.equal(repairMathForDisplay(expected), expected); + } +}); +test("native substack rows and literal document escapes in genuine comments remain literal", () => { + const source = math`$\substack{a\\beta}$`; + assert.equal(repairMathForDisplay(source), source); + const comment = "$x % " + math`literal \n\n` + "\ny$"; + assert.equal(repairMathForDisplay(comment), comment); +}); +test("inline lost prefixes recover only known full commands; display newlines remain whitespace", () => { + assert.equal(repairMathForDisplay("$\nabla f$"), math`$\nabla f$`); + assert.equal(repairMathForDisplay("$x+\ny$"), "$x+\ny$"); + assert.equal(repairMathForDisplay("$x+\nu$"), math`$x+\nu$`); // approved typical-inline-authoring policy + assert.equal(repairMathForDisplay("$$\nabla f\n$$"), "$$\nabla f\n$$"); // display source whitespace +}); +test("physical display newlines and genuine row breaks/comments survive", () => { + const source = "$$\n" + math`\begin{aligned}a&=b\\% row comment` + "\nc&=d\end{aligned}\n$$"; + assert.equal(repairMathForDisplay(source), source); +}); +test("proper nu survives every context, including fields with document escapes", () => { + for (const source of [math`$\nu$`, "$$\n" + math`\nu` + "\n$$", math`$\nabla f+\nu+\neq$`]) { + assert.equal(repairMathForDisplay(source), source); + } + assert.equal(repairMathForDisplay(math`First.\n\n$\nu$`), "First.\n\n" + math`$\nu$`); + assert.equal(repairMathForDisplay("$$\nu\n$$"), "$$\nu\n$$"); // LF + variable u in display +}); +test("tabs recover known commands including to, not arbitrary whitespace", () => { + assert.equal(repairMathForDisplay("$$\nx\t+\ty\n$$"), "$$\nx\t+\ty\n$$"); + assert.equal(repairMathForDisplay("$a\to b$"), math`$a\to b$`); + assert.equal(repairMathForDisplay("$$\na\to b\n$$"), "$$\n" + math`a\to b` + "\n$$"); + assert.equal(repairMathForDisplay("$$\r\nx+1\r\n$$"), "$$\r\nx+1\r\n$$"); +}); +test("Markdown containers protect literal code and do not swallow following content", () => { + for (const source of [ + "> ~~~text\n> " + math`$\\alpha$ \n\n` + "\n> ~~~", + "> > ~~~text\n> > " + math`$\\alpha$` + "\n> > ~~~", + "- ~~~text\n " + math`$\\alpha$` + "\n ~~~", + "> ~~~text\n> " + math`$\\alpha$` + "\n\n~~~text\n" + math`$\\beta$` + "\n~~~", + ]) { + assert.equal(repairMathForDisplay(source), source); + assert.equal(transformMathDelimiters(source), source); + assert.equal(repairMathForDisplay(source + "\n\n" + math`$\\gamma$`), source + "\n\n" + math`$\gamma$`); + } +}); +test("numeric percentages recover, including percentages inside text groups", () => { + for (const [source, expected] of [ + [math`$60\\%$`, math`$60\%$`], + [math`$50\\%+10\\%=60\\%$`, math`$50\%+10\%=60\%$`], + [math`$\text{ (50\\%)}$`, math`$\text{ (50\%)}$`], + ] as const) assert.equal(repairMathForDisplay(source), expected); +}); +test("numeric row breaks followed by genuine comments are not percentage escapes", () => { + const source = "$60\\\\% explanation of this row\nx$"; + assert.equal(repairMathForDisplay(source), source); + const ambiguous = math`$x\\%$`; + assert.equal(repairMathForDisplay(ambiguous), ambiguous); +}); +test("inline and fenced code retain all bytes, including legacy delimiters and actual control characters", () => { + const code = "```text\n\\(x\nabla\\)\n\\n\\n\n```\n" + "`" + math`\n\n $60\\%$ \(x\)` + "`"; + assert.equal(repairMathForDisplay(code), code); + assert.equal(restoreMathEscapes(code), code); +}); +test("math matching cannot consume a blank paragraph or a code span", () => { + const source = "Price $5.\n\nThen $x$ and `code` remains."; + assert.equal(repairMathForDisplay(source), source); + assert.equal(repairMathForDisplay("$not `code` math$"), "$not `code` math$"); +}); +test("Windows paths and correctly written n-commands are not document escapes", () => { + const source = math`Path C:\new\notes; symbols $\nu+\ni+\neq+\nabla f$.\n\nNext paragraph.`; + assert.equal(repairMathForDisplay(source), math`Path C:\new\notes; symbols $\nu+\ni+\neq+\nabla f$.` + "\n\nNext paragraph."); +}); +test("declared n-prefixed macros are commands, not document escapes", () => { + const source = math`$\newcommand{\nThing}{x}\nThing$.\n\nNext.`; + assert.equal(repairMathForDisplay(source), math`$\newcommand{\nThing}{x}\nThing$.` + "\n\nNext."); +}); +test("partial recovery cannot turn otherwise parseable TeX into a missing-argument error", () => { + const source = math`$x\\frac{a}$`; + assert.equal(repairMathForDisplay(source), source); +}); +test("legacy compatibility and recovery share code barriers, including tilde and indentation", () => { + for (const source of [" " + math`\(x\) \n\n`, "\\[\n~~~tex\n" + math`\n\n \(x\)` + "\n\\]\n~~~"]) { + assert.equal(repairMathForDisplay(source), source); + assert.equal(transformMathDelimiters(source), source); + } + const comment = "$60% " + math`\n\n` + "\nx$"; + assert.equal(repairMathForDisplay(comment), comment); +}); +test("recovery converges after one pass, including multiple debris characters", () => { + for (const source of ["$\forall x,\theta+\to$", math`First.\n\n$$\nJ=\\frac{1}{2}+60\\%\n$$`, + math`$\begin{pmatrix}a&b\\c&d\end{pmatrix}$`, "```text\n\\(\nabla\\)\n```", "$\nabla f$", + ]) { + const once = repairMathForDisplay(source); + assert.equal(repairMathForDisplay(once), once); + } +}); diff --git a/tests/fixtures/tool-rendering/document-percent-regressions.json b/tests/fixtures/tool-rendering/document-percent-regressions.json new file mode 100644 index 0000000..fd5ed9b --- /dev/null +++ b/tests/fixtures/tool-rendering/document-percent-regressions.json @@ -0,0 +1,90 @@ +[ + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo321f7_4202fae0", + "source": "messages.1.content.1.arguments.explanation", + "model": "google/gemini-3.7-flash", + "kind": "percent-at-display-end", + "raw": "To find the posterior probability $P(D \\\\mid +)$, we apply Bayes' theorem:\n$$\nP(D \\\\mid +) = \\\\frac{P(D \\\\cap +)}{P(+)} = \\\\frac{P(D)P(+ \\\\mid D)}{P(D)P(+ \\\\mid D) + P(\\\\neg D)P(+ \\\\mid \\\\neg D)}\n$$\n\nGiven:\n- $P(D) = 0.01$ (and thus $P(\\\\neg D) = 0.99$)\n- $P(+ \\\\mid D) = 0.90$\n- $P(+ \\\\mid \\\\neg D) = 0.05$\n\nEvaluating the numerator and denominator:\n$$\nP(D \\\\cap +) = 0.01 \\\\times 0.90 = 0.009\n$$\n$$\nP(\\\\neg D \\\\cap +) = 0.99 \\\\times 0.05 = 0.0495\n$$\n$$\nP(+) = 0.009 + 0.0495 = 0.0585\n$$\n\nTherefore:\n$$\nP(D \\\\mid +) = \\\\frac{0.009}{0.0585} = \\\\frac{9}{58.5} = \\\\frac{2}{13} \\\\approx 15.4\\\\%\n$$" + }, + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo321f7_4202fae0", + "source": "messages.5.content.1.arguments.explanation", + "model": "google/gemini-3.7-flash", + "kind": "percent-at-display-end", + "raw": "Because the two tests are conditionally independent given disease status:\n$$\nP(T_1 = +, T_2 = + \\\\mid D) = P(T_1 = + \\\\mid D) \\\\cdot P(T_2 = + \\\\mid D) = 0.90 \\\\times 0.90 = 0.81\n$$\n$$\nP(T_1 = +, T_2 = + \\\\mid \\\\neg D) = P(T_1 = + \\\\mid \\\\neg D) \\\\cdot P(T_2 = + \\\\mid \\\\neg D) = 0.10 \\\\times 0.10 = 0.01\n$$\n\nNow calculate the joint probabilities:\n$$\nP(D \\\\cap T_1 = + \\\\cap T_2 = +) = P(D) \\\\cdot P(T_1 = +, T_2 = + \\\\mid D) = 0.01 \\\\times 0.81 = 0.0081\n$$\n$$\nP(\\\\neg D \\\\cap T_1 = + \\\\cap T_2 = +) = P(\\\\neg D) \\\\cdot P(T_1 = +, T_2 = + \\\\mid \\\\neg D) = 0.99 \\\\times 0.01 = 0.0099\n$$\n\nTotal probability of two positive tests:\n$$\nP(T_1 = +, T_2 = +) = 0.0081 + 0.0099 = 0.0180\n$$\n\nApplying Bayes' theorem:\n$$\nP(D \\\\mid T_1 = +, T_2 = +) = \\\\frac{0.0081}{0.0180} = \\\\frac{81}{180} = \\\\frac{9}{20} = 45\\\\%\n$$" + }, + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo4op90_74572548", + "source": "messages.1.content.2.arguments.explanation", + "language": "pt", + "model": "deepseek/deepseek-v4.1-flash", + "kind": "percent-at-display-end", + "raw": "Em $10\\\\,000$ pessoas: $100$ estão doentes e $99$ dessas testam positivo; $9\\\\,900$ estão saudáveis e $5\\\\%$ delas, isto é $495$, testam positivo. Total de positivos: $99 + 495 = 594$. Entre os positivos, a fração de doentes é\\n$$\\n\\\\frac{99}{594} = \\\\frac{1}{6} \\\\approx 16{,}7\\\\%\\n$$\\nA base pequena de doentes faz os falsos positivos ($495$) dominarem os verdadeiros positivos ($99$)." + }, + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo4op90_74572548", + "source": "messages.3.content.2.arguments.explanation", + "language": "pt", + "model": "deepseek/deepseek-v4.1-flash", + "kind": "percent-at-display-end", + "raw": "Continua valendo a mesma estrutura de Bayes, agora com números diferentes. Em $10\\\\,000$ pessoas: $200$ doentes, das quais $90\\\\%$ testam positivo $= 180$; $9\\\\,800$ saudáveis, das quais $5\\\\%$ testam positivo $= 490$ falsos positivos. Total de positivos: $180 + 490 = 670$.\\n$$\\nP(\\\\text{doente} \\\\mid +) = \\\\frac{180}{670} \\\\approx 26{,}9\\\\%\\n$$\\nNote que a especificidade $95\\\\%$ entra na conta pela sua taxa de falso positivo $1 - 0{,}95 = 0{,}05$, pesada por $P(\\\\text{saudável}) = 0{,}98$." + }, + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo8kgtc_65767ffd", + "source": "messages.5.content.1.arguments.question", + "language": "es", + "model": "xiaomi/mimo-v2.6-flash", + "kind": "document-math-framing", + "raw": "Una enfermedad afecta al $1\\%$ de una población. La prueba tiene una sensibilidad del $99\\%$ (probabilidad de dar positivo si la enfermedad está presente) y una tasa de falsos positivos del $5\\%$ (probabilidad de dar positivo si no está presente). Una persona al azar da positivo. ¿Cuál es la probabilidad de que realmente tenga la enfermedad?\\n\\n$$\\nP(\\text{enf} \\mid +) \\;=\\; ?$$" + }, + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo91klg_5332fa79", + "source": "messages.3.content.1.arguments.explanation", + "language": "zh-Hans", + "model": "xiaomi/mimo-v2.6-flash", + "kind": "percent-at-display-end", + "raw": "条件概率把样本空间缩小到条件所指定的那一部分。已知\"检测阳性\",人群就从 $1000$ 人缩到 $140$ 人,其中患病的有 $45$ 人,所以\\n$$\\nP(\\\\text{患病}\\\\mid\\\\text{阳性})=\\\\frac{P(\\\\text{患病}\\\\cap\\\\text{阳性})}{P(\\\\text{阳性})}=\\\\frac{45/1000}{140/1000}=\\\\frac{45}{140}\\\\approx 32.1\\\\%.\\n$$\\n另外三个数各有各的含义:$\\\\frac{45}{50}=90\\\\%$ 是 $P(\\\\text{阳性}\\\\mid\\\\text{患病})$(敏感度,条件方向反了);$\\\\frac{45}{1000}=4.5\\\\%$ 是联合概率 $P(\\\\text{患病}\\\\cap\\\\text{阳性})$(没做条件化);$\\\\frac{140}{1000}=14\\\\%$ 是 $P(\\\\text{阳性})$(不分是否患病的总阳性率)。" + }, + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo91klg_5332fa79", + "source": "messages.7.content.1.arguments.explanation", + "language": "zh-Hans", + "model": "xiaomi/mimo-v2.6-flash", + "kind": "percent-at-display-end", + "raw": "用 $10000$ 人(或直接用全概率公式)算分母:\\n$$\\n\\\\begin{aligned}\\n\\\\text{患病且阳性}&=10000\\\\times 0.01\\\\times 0.99=99,\\\\\\\\\\n\\\\text{未患病但阳性}&=10000\\\\times 0.99\\\\times 0.05=495.\\n\\\\end{aligned}\\n$$\\n阳性总数 $=99+495=594$,所以\\n$$\\nP(D\\\\mid +)=\\\\frac{99}{594}\\\\approx 16.7\\\\%.\\n$$\\n各错误选项对应的具体失误:$99\\\\%$ 是把敏感度 $P(+\\\\mid D)$ 当成了 $P(D\\\\mid +)$(条件方向颠倒);$95.2\\\\%=\\\\frac{0.99}{0.99+0.05}$ 是把阳性率和假阳性率直接当人数用,漏掉了基础发生率的加权($99\\\\%$ 的人未患病,所以少量假阳性也架不住人多);$1\\\\%$ 是看到了阳性结果却不更新概率。" + }, + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo91klg_5332fa79", + "source": "messages.9.content.1.arguments.explanation", + "language": "zh-Hans", + "model": "xiaomi/mimo-v2.6-flash", + "kind": "percent-at-display-end", + "raw": "两次阳性后的后验,用同样的公式但似然要平方:\\n$$\\n\\\\begin{aligned}\\nP(++\\\\mid D)&=0.99^2=0.9801,\\\\\\\\\\nP(++\\\\mid D')&=0.05^2=0.0025.\\n\\\\end{aligned}\\n$$\\n$$\\nP(D\\\\mid ++)\\\\frac{0.99^2\\\\times 0.01}{0.99^2\\\\times 0.01+0.05^2\\\\times 0.99}=\\\\frac{0.009801}{0.009801+0.002475}\\\\approx 79.8\\\\%.\\n$$\\n关键在于**两条分支都要平方**:患病分支的似然变成 $0.99^2$,未患病分支的假阳性率变成 $0.05^2$($5\\%$ 是很小的数,平方后变得更小,所以假阳性这一侧收缩得比患病侧更狠,后验从 $16.7\\\\%$ 跳到约 $80\\\\%$)。其他选项的失误:$98\\\\%$ 是把 $P(++\\\\mid D)$ 当成了 $P(D\\\\mid ++)$;$16.5\\\\%$ 只对患病分支用了两次独立,未患病分支仍用单次假阳性率;$33\\\\%$ 是把单次后验直接乘 $2$——两次阳性提供的证据不是相加的,而要在似然层面相乘。" + }, + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo9c830_3866e4a6", + "source": "messages.7.content.1.arguments.explanation", + "language": "zh-Hans", + "model": "anthropic/claude-haiku-5.5", + "kind": "percent-at-display-end", + "raw": "阳性总人数:真阳性 $100\\\\times0.9=90$,假阳性 $9900\\\\times0.05=495$,合计 $585$。\n\n所求的是阳性者中患病者的占比,分母是全部阳性者:\n$$\n\\\\frac{90}{585}\\\\approx15.4\\\\%\n$$\n\n$90\\\\%$ 是患病者中阳性的比例,分母错成了患病者;$84.6\\\\%$ 是阳性者中健康者的占比,回答的是另一个问题;$5\\\\%$ 是健康者中的假阳性率,分母是健康人群。" + }, + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo9nu8w_dcc7add9", + "source": "messages.3.content.1.arguments.explanation", + "language": "id", + "model": "xiaomi/mimo-v2.6-flash", + "kind": "percent-at-display-end", + "raw": "Pakai aturan Bayes dengan menghitung dulu kejadian gabungan dalam satu kelompok besar, misalnya $10.000$ orang:\\n\\n- Sakit dan positif: $10.000 \\\\times 0{,}01 \\\\times 0{,}99 = 99$ orang.\\n- Sehat dan positif: $10.000 \\\\times 0{,}99 \\\\times 0{,}05 = 495$ orang.\\n\\nSemua yang hasilnya positif: $99 + 495 = 594$ orang. Maka\\n$$\\nP(\\\\text{sakit}\\\\mid +) = \\\\frac{99}{594} = \\\\frac{1}{6} \\\\approx 16{,}7\\\\%.\\n$$\\n\\nYang menipu di sini adalah base rate: kelompok sehat jauh lebih besar, sehingga meski hanya 5% dari mereka yang salah positif, jumlahnya tetap 5 kali lipat jumlah positif yang benar. Nilai $95{,}2\\\\%$ muncul kalau kelompok sakit dan sehat dianggap sama besar ($\\\\frac{99}{99+5}$); nilai $99\\\\%$ muncul kalau arah kondisinya dibalik, membaca $P(+\\\\mid \\\\text{sakit})$ sebagai $P(\\\\text{sakit}\\\\mid +)$." + } +] diff --git a/tests/fixtures/tool-rendering/matrix-regressions.json b/tests/fixtures/tool-rendering/matrix-regressions.json new file mode 100644 index 0000000..48d409d --- /dev/null +++ b/tests/fixtures/tool-rendering/matrix-regressions.json @@ -0,0 +1,135 @@ +{ + "notice": "Unmodified model-authored fields from the frozen multilingual corpus. oldDisplay records the diagnosed application damage, not an expected output.", + "cases": [ + { + "run": "pilot-20261025-01", + "model": "openai/gpt-6-luna", + "language": "en", + "replicate": 1, + "chatId": "chat_mvo1rzjs_056eac45", + "source": "messages.5.content.1.arguments.explanation", + "raw": "For a $2\\times2$ matrix, $\\det\\begin{bmatrix}a&b\\\\c&d\\end{bmatrix}=ad-bc$. Here $3\\cdot2-6\\cdot1=0$. A square matrix is invertible exactly when its determinant is nonzero, so $M$ is singular. Its columns are also proportional, which matches the determinant-zero result.", + "oldDisplay": "For a $2\\times2$ matrix, $\\det\\begin{bmatrix}a&b\\c&d\\end{bmatrix}=ad-bc$. Here $3\\cdot2-6\\cdot1=0$. A square matrix is invertible exactly when its determinant is nonzero, so $M$ is singular. Its columns are also proportional, which matches the determinant-zero result." + }, + { + "run": "pilot-20261025-01", + "model": "openai/gpt-6-luna", + "language": "en", + "replicate": 1, + "chatId": "chat_mvo1sdnj_8f2d8c5e", + "source": "messages.9.content.1.arguments.explanation", + "raw": "The Jacobian has one row per output and one column per input: $J_F=\\begin{pmatrix}2x&1\\\\y&x\\end{pmatrix}$. At $(1,2)$ this is $\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}$.", + "oldDisplay": "The Jacobian has one row per output and one column per input: $J_F=\\begin{pmatrix}2x&1\\y&x\\end{pmatrix}$. At $(1,2)$ this is $\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}$." + }, + { + "run": "pilot-20261025-01", + "model": "anthropic/claude-haiku-5.5", + "language": "en", + "replicate": 1, + "chatId": "chat_mvo30cfe_57114723", + "source": "messages.3.content.1.arguments.explanation", + "raw": "$A-\\lambda I=\\begin{pmatrix}2-\\lambda&1\\\\1&2-\\lambda\\end{pmatrix}$, and for a $2\\times2$ matrix $\\det\\begin{pmatrix}p&q\\\\r&s\\end{pmatrix}=ps-qr$. So $$\\det(A-\\lambda I)=(2-\\lambda)^2-1\\cdot1=\\lambda^2-4\\lambda+3.$$ Its roots $\\lambda=1,3$ are the eigenvalues. Option $b$ drops the minus sign in $ps-qr$; $c$ subtracts the constant incorrectly; $d$ flips the sign of the linear term.", + "oldDisplay": "$A-\\lambda I=\\begin{pmatrix}2-\\lambda&1\\\\1&2-\\lambda\\end{pmatrix}$, and for a $2\\times2$ matrix $\\det\\begin{pmatrix}p&q\\r&s\\end{pmatrix}=ps-qr$. So $$\\det(A-\\lambda I)=(2-\\lambda)^2-1\\cdot1=\\lambda^2-4\\lambda+3.$$ Its roots $\\lambda=1,3$ are the eigenvalues. Option $b$ drops the minus sign in $ps-qr$; $c$ subtracts the constant incorrectly; $d$ flips the sign of the linear term." + }, + { + "run": "multilingual-20261025-01-zh-Hans-r1", + "model": "openai/gpt-6-luna", + "language": "zh-Hans", + "replicate": 1, + "chatId": "chat_mvo60bsn_1cbbe884", + "source": "messages.3.content.1.arguments.question", + "raw": "对 $2\\times2$ 矩阵 $A=\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$,以下哪组关于行列式的说法全都正确?", + "oldDisplay": "对 $2\\times2$ 矩阵 $A=\\begin{pmatrix}a&b\\c&d\\end{pmatrix}$,以下哪组关于行列式的说法全都正确?" + }, + { + "run": "multilingual-20261025-01-zh-Hans-r1", + "model": "xiaomi/mimo-v2.6-flash", + "language": "zh-Hans", + "replicate": 1, + "chatId": "chat_mvo66u6w_a233fc16", + "source": "messages.9.content.2.arguments.explanation", + "raw": "雅可比矩阵按“第 $i$ 行 = 第 $i$ 个分量对所有自变量的偏导”排布:\n$$\nJ_{\\mathbf F}=\n\\begin{pmatrix}\n\\dfrac{\\partial (xy)}{\\partial x} & \\dfrac{\\partial (xy)}{\\partial y}\\\\[6pt]\n\\dfrac{\\partial (x+y^2)}{\\partial x} & \\dfrac{\\partial (x+y^2)}{\\partial y}\n\\end{pmatrix}\n=\n\\begin{pmatrix}\ny & x\\\\\n1 & 2y\n\\end{pmatrix}.\n$$\n注意 $\\partial(xy)/\\partial x=y$($y$ 当常数),$\\partial(xy)/\\partial y=x$。转置形式 $\\begin{pmatrix}y&1\\\\x&2y\\end{pmatrix}$ 把偏导排成了列;$\\begin{pmatrix}y&x\\\\2y&1\\end{pmatrix}$ 把第二行的两个分量次序调换了;$\\begin{pmatrix}x&y\\\\1&2y\\end{pmatrix}$ 把第一行的 $x,y$ 位置写反了。", + "oldDisplay": "雅可比矩阵按“第 $i$ 行 = 第 $i$ 个分量对所有自变量的偏导”排布:\n$$\nJ_{\\mathbf F}=\n\\begin{pmatrix}\n\\dfrac{\\partial (xy)}{\\partial x} & \\dfrac{\\partial (xy)}{\\partial y}\\\\[6pt]\n\\dfrac{\\partial (x+y^2)}{\\partial x} & \\dfrac{\\partial (x+y^2)}{\\partial y}\n\\end{pmatrix}\n=\n\\begin{pmatrix}\ny & x\\\\\n1 & 2y\n\\end{pmatrix}.\n$$\n注意 $\\partial(xy)/\\partial x=y$($y$ 当常数),$\\partial(xy)/\\partial y=x$。转置形式 $\\begin{pmatrix}y&1\\x&2y\\end{pmatrix}$ 把偏导排成了列;$\\begin{pmatrix}y&x\\\\2y&1\\end{pmatrix}$ 把第二行的两个分量次序调换了;$\\begin{pmatrix}x&y\\\\1&2y\\end{pmatrix}$ 把第一行的 $x,y$ 位置写反了。" + }, + { + "run": "multilingual-20261025-01-id-r1", + "model": "openai/gpt-6-luna", + "language": "id", + "replicate": 1, + "chatId": "chat_mvo6q2lq_6af14437", + "source": "messages.7.content.1.arguments.explanation", + "raw": "Pada $(x,y)=(1,0)$, $H=(1,1)$. Jacobian $G$ di $(1,1)$ adalah $\\begin{pmatrix}2u&1\\\\v&u\\end{pmatrix}=\\begin{pmatrix}2&1\\\\1&1\\end{pmatrix}$, sedangkan $J_H=\\begin{pmatrix}1&1\\\\1&-1\\end{pmatrix}$. Aturan rantai dengan konvensi ini memberi $J_{G\\circ H}=J_G(H(x,y))J_H(x,y)$, yakni $\\begin{pmatrix}3&1\\\\2&0\\end{pmatrix}$.", + "oldDisplay": "Pada $(x,y)=(1,0)$, $H=(1,1)$. Jacobian $G$ di $(1,1)$ adalah $\\begin{pmatrix}2u&1\\v&u\\end{pmatrix}=\\begin{pmatrix}2&1\\\\1&1\\end{pmatrix}$, sedangkan $J_H=\\begin{pmatrix}1&1\\\\1&-1\\end{pmatrix}$. Aturan rantai dengan konvensi ini memberi $J_{G\\circ H}=J_G(H(x,y))J_H(x,y)$, yakni $\\begin{pmatrix}3&1\\\\2&0\\end{pmatrix}$." + }, + { + "run": "multilingual-20261025-01-id-r1", + "model": "z-ai/glm-5.3-flash", + "language": "id", + "replicate": 1, + "chatId": "chat_mvo77f3x_135f8d66", + "source": "messages.7.content.2.arguments.explanation", + "raw": "Vektor eigen memenuhi $(A-\\lambda I)v=0$. Untuk $\\lambda=2$:\n$$(A-2I)v=\\begin{pmatrix}1&1\\\\0&0\\end{pmatrix}\\begin{pmatrix}v_1\\\\v_2\\end{pmatrix}=0\\;\\Rightarrow\\;v_1+v_2=0\\;\\Rightarrow\\;v=\\begin{pmatrix}1\\\\-1\\end{pmatrix}$$\n(bisa dikali skalar berapa pun). Cek: $Av=\\begin{pmatrix}3-1\\\\-2\\end{pmatrix}=\\begin{pmatrix}2\\\\-2\\end{pmatrix}=2v$. Perhatikan $v=(1,0)$ adalah vektor eigen untuk $\\lambda=3$, bukan $\\lambda=2$.", + "oldDisplay": "Vektor eigen memenuhi $(A-\\lambda I)v=0$. Untuk $\\lambda=2$:\n$$(A-2I)v=\\begin{pmatrix}1&1\\\\0&0\\end{pmatrix}\\begin{pmatrix}v_1\\v_2\\end{pmatrix}=0\\;\\Rightarrow\\;v_1+v_2=0\\;\\Rightarrow\\;v=\\begin{pmatrix}1\\\\-1\\end{pmatrix}$$\n(bisa dikali skalar berapa pun). Cek: $Av=\\begin{pmatrix}3-1\\\\-2\\end{pmatrix}=\\begin{pmatrix}2\\\\-2\\end{pmatrix}=2v$. Perhatikan $v=(1,0)$ adalah vektor eigen untuk $\\lambda=3$, bukan $\\lambda=2$." + }, + { + "run": "multilingual-20261025-01-en-r2", + "model": "openai/gpt-6-luna", + "language": "en", + "replicate": 2, + "chatId": "chat_mvo7ei0l_9263330e", + "source": "messages.3.content.1.arguments.question", + "raw": "For a $2\\times2$ matrix $A=\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$, which statement correctly links its determinant to area scaling and invertibility?", + "oldDisplay": "For a $2\\times2$ matrix $A=\\begin{pmatrix}a&b\\c&d\\end{pmatrix}$, which statement correctly links its determinant to area scaling and invertibility?" + }, + { + "run": "multilingual-20261025-01-en-r2", + "model": "openai/gpt-6-luna", + "language": "en", + "replicate": 2, + "chatId": "chat_mvo7exl3_e1fe93cd", + "source": "messages.1.content.1.arguments.explanation", + "raw": "First evaluate the Jacobian of $F$ at $(1,2)$:\n$$\nJ_F(x,y)=\\begin{pmatrix}2x&1\\\\y&x\\end{pmatrix},\\qquad J_F(1,2)=\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}.\n$$\nThe velocity is $\\gamma'(1)=(1,2)$. The chain rule gives\n$$\n\\frac{d}{dt}F(\\gamma(t))\\bigg|_{t=1}=J_F(1,2)\\begin{pmatrix}1\\\\2\\end{pmatrix}=\\begin{pmatrix}4\\\\4\\end{pmatrix}.\n$$\nWait, recomputing the component derivatives directly: $F(\\gamma(t))=(t^2+(1+t^2),\\ t(1+t^2))=(1+2t^2,t+t^3)$, whose derivative at $1$ is $(4,4)$. The listed options do not include this value, so this question is invalid.", + "oldDisplay": "First evaluate the Jacobian of $F$ at $(1,2)$:\n$$\nJ_F(x,y)=\\begin{pmatrix}2x&1\\y&x\\end{pmatrix},\\qquad J_F(1,2)=\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}.\n$$\nThe velocity is $\\gamma'(1)=(1,2)$. The chain rule gives\n$$\n\\frac{d}{dt}F(\\gamma(t))\\bigg|_{t=1}=J_F(1,2)\\begin{pmatrix}1\\\\2\\end{pmatrix}=\\begin{pmatrix}4\\\\4\\end{pmatrix}.\n$$\nWait, recomputing the component derivatives directly: $F(\\gamma(t))=(t^2+(1+t^2),\\ t(1+t^2))=(1+2t^2,t+t^3)$, whose derivative at $1$ is $(4,4)$. The listed options do not include this value, so this question is invalid." + }, + { + "run": "multilingual-20261025-01-en-r2", + "model": "openai/gpt-6-luna", + "language": "en", + "replicate": 2, + "chatId": "chat_mvo7exl3_e1fe93cd", + "source": "messages.3.content.1.arguments.explanation", + "raw": "For $F=(F_1,F_2)$, each row contains the partial derivatives of one output component: $J_F=\\begin{pmatrix}\\partial F_1/\\partial x&\\partial F_1/\\partial y\\\\\\partial F_2/\\partial x&\\partial F_2/\\partial y\\end{pmatrix}=\\begin{pmatrix}2x&1\\\\y&x\\end{pmatrix}$. Substituting $(x,y)=(1,2)$ gives $\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}$.", + "oldDisplay": "For $F=(F_1,F_2)$, each row contains the partial derivatives of one output component: $J_F=\\begin{pmatrix}\\partial F_1/\\partial x&\\partial F_1/\\partial y\\\\\\partial F_2/\\partial x&\\partial F_2/\\partial y\\end{pmatrix}=\\begin{pmatrix}2x&1\\y&x\\end{pmatrix}$. Substituting $(x,y)=(1,2)$ gives $\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}$." + }, + { + "run": "multilingual-20261025-01-pt-r2", + "model": "openai/gpt-6-luna", + "language": "pt", + "replicate": 2, + "chatId": "chat_mvo7wn6c_d63ef3f1", + "source": "messages.7.content.1.arguments.explanation", + "raw": "Para uma matriz $2\\times2$, $\\det\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}=ad-bc$. Logo, $\\det(C)=2\\cdot3-1\\cdot5=1$. O determinante é o fator assinado de escala de área: valor absoluto $1$ preserva área, e sinal positivo indica que a orientação não é invertida. Como o determinante é diferente de zero, $C$ é invertível.", + "oldDisplay": "Para uma matriz $2\\times2$, $\\det\\begin{pmatrix}a&b\\c&d\\end{pmatrix}=ad-bc$. Logo, $\\det(C)=2\\cdot3-1\\cdot5=1$. O determinante é o fator assinado de escala de área: valor absoluto $1$ preserva área, e sinal positivo indica que a orientação não é invertida. Como o determinante é diferente de zero, $C$ é invertível." + }, + { + "run": "multilingual-20261025-01-es-r2", + "model": "openai/gpt-6-luna", + "language": "es", + "replicate": 2, + "chatId": "chat_mvo8j89a_f6fc1cc0", + "source": "messages.3.content.1.arguments.explanation", + "raw": "La regla de la cadena es $D(F\\circ g)=DF(g(s,t))Dg(s,t)$. Aquí $g(1,2)=(3,2)$, $DF(u,v)=\\begin{pmatrix}2u&1\\\\v&u\\end{pmatrix}$ y $Dg(s,t)=\\begin{pmatrix}1&1\\\\t&s\\end{pmatrix}$. Así, $\\begin{pmatrix}6&1\\\\2&3\\end{pmatrix}\\begin{pmatrix}1&1\\\\2&1\\end{pmatrix}=\\begin{pmatrix}8&7\\\\8&5\\end{pmatrix}$.", + "oldDisplay": "La regla de la cadena es $D(F\\circ g)=DF(g(s,t))Dg(s,t)$. Aquí $g(1,2)=(3,2)$, $DF(u,v)=\\begin{pmatrix}2u&1\\v&u\\end{pmatrix}$ y $Dg(s,t)=\\begin{pmatrix}1&1\\t&s\\end{pmatrix}$. Así, $\\begin{pmatrix}6&1\\\\2&3\\end{pmatrix}\\begin{pmatrix}1&1\\\\2&1\\end{pmatrix}=\\begin{pmatrix}8&7\\\\8&5\\end{pmatrix}$." + }, + { + "run": "multilingual-20261025-01-id-r2", + "model": "openai/gpt-6-luna", + "language": "id", + "replicate": 2, + "chatId": "chat_mvo9mesu_dbb56f1f", + "source": "messages.9.content.1.arguments.explanation", + "raw": "Untuk $\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$, determinannya $ad-bc$. Jadi $\\det(C)=2\\cdot3-5\\cdot1=1$.", + "oldDisplay": "Untuk $\\begin{pmatrix}a&b\\c&d\\end{pmatrix}$, determinannya $ad-bc$. Jadi $\\det(C)=2\\cdot3-5\\cdot1=1$." + } + ] +} diff --git a/tests/fixtures/tool-rendering/mixed-escaping-regressions.json b/tests/fixtures/tool-rendering/mixed-escaping-regressions.json new file mode 100644 index 0000000..5489f6a --- /dev/null +++ b/tests/fixtures/tool-rendering/mixed-escaping-regressions.json @@ -0,0 +1,19 @@ +{ + "provenance": "multilingual-20261025-01; original collection findings, not the failed r1 rendering", + "cases": [ + { + "chatId": "chat_mvo6kxzl_540b570e", + "source": "messages.1.content.2.arguments.explanation", + "model": "z-ai/glm-5.3-flash", + "raw": "乘法规则是\"左行的元素依次乘右列的元素再相加\":\\n$$\\nAB=\\begin{pmatrix}1\\\\cdot3+2\\\\cdot0 & 1\\\\cdot1+2\\\\cdot2\\\\\\\\ 0\\\\cdot3+1\\\\cdot0 & 0\\\\cdot1+1\\\\cdot2\\\\end{pmatrix}=\\begin{pmatrix}3 & 5\\\\\\\\ 0 & 2\\\\end{pmatrix}\\n$$\\n选项 b 其实是 $BA$($\\\\begin{pmatrix}3 & 7\\\\\\\\ 0 & 2\\\\end{pmatrix}$)——顺序交换后结果不同,这正是矩阵乘法不交换的例子。选项 c 是对应位置直接相加,那是加法不是乘法。", + "oldDisplay": "乘法规则是\"左行的元素依次乘右列的元素再相加\":\\n$$\\nAB=\\begin{pmatrix}1\\cdot3+2\\cdot0 & 1\\cdot1+2\\cdot2\\\\\\\\ 0\\cdot3+1\\cdot0 & 0\\cdot1+1\\cdot2\\end{pmatrix}=\\begin{pmatrix}3 & 5\\\\\\\\ 0 & 2\\end{pmatrix}\\n$$\\n选项 b 其实是 $BA$($\\begin{pmatrix}3 & 7\\\\\\\\ 0 & 2\\end{pmatrix}$)——顺序交换后结果不同,这正是矩阵乘法不交换的例子。选项 c 是对应位置直接相加,那是加法不是乘法。" + }, + { + "chatId": "chat_mvo9oquq_74bfd7f6", + "source": "messages.3.content.1.arguments.explanation", + "model": "xiaomi/mimo-v2.6-flash", + "raw": "Perkalian matriks $AB$ = baris-kolom: elemen $(i,j)$ hasil adalah titik-antara baris $i$ dari $A$ dengan kolom $j$ dari $B$.\\n\\n- $(1,1): (1,2)\\cdot(2,0) = 1\\cdot2 + 2\\cdot1 = 4$\\n- $(1,2): (1,2)\\cdot(0,2) = 1\\cdot0 + 2\\cdot2 = 4$\\n- $(2,1): (3,4)\\cdot(2,0) = 3\\cdot2 + 4\\cdot1 = 10$\\n- $(2,2): (3,4)\\cdot(0,2) = 3\\cdot0 + 4\\cdot2 = 8$\\n\\n$$AB = \\\\begin{pmatrix} 4 & 4 \\\\\\\\ 10 & 8 \\\\end{pmatrix}$$\\n\\nPilihan lain adalah tiga kesalahan berbeda: $\\begin{pmatrix} 2 & 4 \\\\ 7 & 10 \\\\end{pmatrix}$ adalah $BA$ (urutan dibalik), $\\begin{pmatrix} 4 & 10 \\\\ 4 & 8 \\\\end{pmatrix}$ adalah transpos hasil (peletakan elemen tertukar), dan $\\begin{pmatrix} 2 & 0 \\\\ 3 & 8 \\\\end{pmatrix}$ adalah perkalian elemen-per-elemen, bukan baris-kolom. Perhatikan juga bahwa $AB \\neq BA$ — perkalian matriks tidak komutatif.", + "oldDisplay": "Perkalian matriks $AB$ = baris-kolom: elemen $(i,j)$ hasil adalah titik-antara baris $i$ dari $A$ dengan kolom $j$ dari $B$.\\n\\n- $(1,1): (1,2)\\cdot(2,0) = 1\\cdot2 + 2\\cdot1 = 4$\\n- $(1,2): (1,2)\\cdot(0,2) = 1\\cdot0 + 2\\cdot2 = 4$\\n- $(2,1): (3,4)\\cdot(2,0) = 3\\cdot2 + 4\\cdot1 = 10$\\n- $(2,2): (3,4)\\cdot(0,2) = 3\\cdot0 + 4\\cdot2 = 8$\\n\\n$$AB = \\begin{pmatrix} 4 & 4 \\\\\\\\ 10 & 8 \\end{pmatrix}$$\\n\\nPilihan lain adalah tiga kesalahan berbeda: $\\begin{pmatrix} 2 & 4 \\\\ 7 & 10 \\end{pmatrix}$ adalah $BA$ (urutan dibalik), $\\begin{pmatrix} 4 & 10 \\\\ 4 & 8 \\end{pmatrix}$ adalah transpos hasil (peletakan elemen tertukar), dan $\\begin{pmatrix} 2 & 0 \\\\ 3 & 8 \\end{pmatrix}$ adalah perkalian elemen-per-elemen, bukan baris-kolom. Perhatikan juga bahwa $AB \\neq BA$ — perkalian matriks tidak komutatif." + } + ] +} diff --git a/tests/repair-math-display.test.tsx b/tests/repair-math-display.test.tsx index 117f123..13673d0 100644 --- a/tests/repair-math-display.test.tsx +++ b/tests/repair-math-display.test.tsx @@ -12,7 +12,7 @@ import { PracticeDock } from "../components/practice-dock.tsx"; import { QuestionDock } from "../components/question-dock.tsx"; import { LatexGlyph } from "../components/latex-glyph.tsx"; import { ProblemCard, ConcludeCard } from "../components/practice-cards.tsx"; -import { MessageResponse } from "../components/ai-elements/message.tsx"; +import { MessageResponse, ToolContentResponse } from "../components/ai-elements/message.tsx"; import { repairMathForDisplay, healOverescapedCommands, restoreMathEscapes, parseQuizInput, parseAskInput, parseExplainInput, activeProblemOf, projectView, @@ -206,17 +206,17 @@ describe("practice display surfaces", () => { 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", () => { + it("teaching shape parsing retains source bytes; presentation alone recovers them", () => { 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({parseAskInput(ask)!.question})); + assert.equal(parseQuizInput(quiz)?.question, doubled); + assert.equal(parseQuizInput(quiz)?.explanation, debris); + assert.equal(parseAskInput(ask)?.question, doubled); + assert.equal(parseExplainInput(explain)?.question, doubled); + assert.equal(parseExplainInput(explain)?.correctAnswer, debris); + assertMath(renderToStaticMarkup({parseAskInput(ask)!.question})); assert.deepEqual({ quiz, ask, explain }, before); }); diff --git a/tests/tool-content-interaction.test.tsx b/tests/tool-content-interaction.test.tsx new file mode 100644 index 0000000..b23f8c6 --- /dev/null +++ b/tests/tool-content-interaction.test.tsx @@ -0,0 +1,78 @@ +import React, { act } from "react"; +(globalThis as { React?: typeof React }).React = React; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createRoot } from "react-dom/client"; +import { parseHTML } from "linkedom"; +import { QuestionDock } from "../components/question-dock.tsx"; +import { AnsweredCard } from "../components/answered-card.tsx"; +import { newAnswerResult, type QuestionAnswerData } from "../src/core/index.ts"; + +const dom = parseHTML(""); +Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }); + +test("actual select → submit → saved result → historical Why uses source data and presentation-only recovery", async () => { + const question = String.raw`Find $\\nabla f$ for $\begin{pmatrix}a&b\\c&d\end{pmatrix}$.`; + const label = String.raw`$\\nabla f$`; + const explanation = String.raw`First paragraph.\n\nThe matrix is $\begin{pmatrix}p&q\\r&s\end{pmatrix}$ and $\\nabla f$ is its gradient.`; + const input = { question, options: [{ label }, { label: "no" }], correctAnswer: label, explanation }; + const before = structuredClone(input); + let answer: QuestionAnswerData | undefined; + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); + try { + await act(async () => root.render( {}} onSubmit={a => { answer = a; }} />)); + assert.equal(host.querySelectorAll("mtr").length, 2, "the live matrix retains two rows"); + const choice = [...host.querySelectorAll('[role="radio"]')].find(button => button.querySelector(".katex"))!; + assert.ok(choice); + await act(async () => choice.click()); + assert.equal(choice.getAttribute("aria-checked"), "true"); + const submit = [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Submit answer"))!; + assert.equal(submit.disabled, false); + // linkedom does not implement the browser's default button→form + // action; dispatch its native submit event through React instead. + await act(async () => submit.closest("form")!.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }))); + assert.ok(answer); + assert.equal(answer.questionText, question); + assert.equal(answer.explanation, explanation); + assert.equal(answer.answers[0]!.value, label, "display recovery must not alter the submitted identity"); + assert.equal(answer.answers[0]!.label, label); + assert.equal(answer.correct, true); + const persisted = JSON.parse(JSON.stringify(newAnswerResult("interactive_source", "probe", answer))); + assert.deepEqual(persisted.details, JSON.parse(JSON.stringify(answer))); + assert.equal(persisted.details.explanation, explanation); + const saved = structuredClone(persisted.details); + await act(async () => root.render()); + const why = [...host.querySelectorAll("button")].find(button => button.textContent === "Why")!; + await act(async () => why.click()); + assert.equal(host.querySelectorAll("mtr").length, 4, "question and revealed explanation retain their rows"); + const paragraphs = host.querySelectorAll('[data-testid="tucked-explanation"] p'); + assert.equal(paragraphs.length, 2, "recovered paragraphs remain distinct in Why"); + assert.ok(paragraphs[0]!.parentElement!.classList.contains("[&>p:first-child]:inline")); + assert.ok(!paragraphs[0]!.parentElement!.classList.contains("[&_p]:inline")); + const annotations = [...host.querySelectorAll('annotation[encoding="application/x-tex"]')].map(n => n.textContent); + assert.equal(annotations.filter(t => t === String.raw`\nabla f`).length, 3); + assert.deepEqual(persisted.details, saved); + assert.deepEqual(input, before); + } finally { await act(async () => root.unmount()); host.remove(); } +}); + +test("committed-answer reveals keep the first paragraph inline without flattening later paragraphs", async () => { + const committed = String.raw`First paragraph.\n\nSecond paragraph with $\\nabla f$ and $\nu$.`; + const answer: QuestionAnswerData = { kind: "explain", questionText: "Explain the gradient.", answers: [], text: "Synthetic answer", committed, dontKnow: false, answeredAt: "2026-10-26T00:00:00.000Z" }; + const input = { question: answer.questionText, correctAnswer: committed }; + const before = structuredClone(answer); + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); + try { + await act(async () => root.render()); + const paragraphs = host.querySelectorAll("p"); + assert.equal(paragraphs.length, 3, "question plus two committed-answer paragraphs"); + assert.ok(paragraphs[1]!.parentElement!.classList.contains("[&>p:first-child]:inline")); + assert.ok(!paragraphs[1]!.parentElement!.classList.contains("[&_p]:inline")); + assert.equal(host.querySelectorAll(".katex-error").length, 0); + assert.deepEqual(answer, before); + } finally { await act(async () => root.unmount()); host.remove(); } +}); diff --git a/tests/tool-content-presentation.test.tsx b/tests/tool-content-presentation.test.tsx new file mode 100644 index 0000000..35c13d0 --- /dev/null +++ b/tests/tool-content-presentation.test.tsx @@ -0,0 +1,164 @@ +import React from "react"; +(globalThis as { React?: typeof React }).React = React; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { parseHTML } from "linkedom"; +import { MessageResponse, ToolContentResponse } from "../components/ai-elements/message.tsx"; +import { QuestionDock } from "../components/question-dock.tsx"; +import { AnsweredCard } from "../components/answered-card.tsx"; +import { answerForPresentation, buildAnswer, normalizeOptions, parseAskInput, parseExplainInput, parseQuizInput, + repairMathForDisplay, resolveCorrect, type QuestionAnswerData, type QuestionData } from "../src/core/index.ts"; + +const fixtures = JSON.parse(readFileSync(new URL("./fixtures/tool-rendering/matrix-regressions.json", import.meta.url), "utf8")) as { + cases: Array<{ chatId: string; source: string; raw: string; oldDisplay: string }>; +}; +const a = String.raw`$\begin{pmatrix}a&b\\c&d\end{pmatrix}$`; +const b = String.raw`$\begin{pmatrix}p&q\\r&s\end{pmatrix}$`; +const brokenA = a.replace(String.raw`\\c`, String.raw`\c`); +const brokenB = b.replace(String.raw`\\r`, String.raw`\r`); +const doubled = String.raw`$\\nabla f$`; +const input = { question: a, options: [{ label: a, value: "a" }, { label: b, value: "b" }], correctAnswer: "a", explanation: a }; +const dom = (html: string) => parseHTML(`${html}`).document; +const render = (text: string, recovered = true) => renderToStaticMarkup( + recovered ? {text} : {text}); +const tex = (html: string) => [...dom(html).querySelectorAll('annotation[encoding="application/x-tex"]')].map(n => n.textContent); + +for (const fixture of fixtures.cases) test(`preserves corpus matrix rows: ${fixture.chatId} ${fixture.source}`, () => { + assert.notEqual(fixture.raw, fixture.oldDisplay); + assert.equal(repairMathForDisplay(fixture.raw), fixture.raw); + const expected = dom(render(fixture.raw, false)); + const actual = dom(render(fixture.raw)); + assert.deepEqual(tex(actual.body.innerHTML), tex(expected.body.innerHTML)); + assert.equal(actual.querySelectorAll("mtr").length, expected.querySelectorAll("mtr").length); + assert.ok(actual.querySelectorAll("mtr").length > dom(render(fixture.oldDisplay, false)).querySelectorAll("mtr").length); + assert.equal(actual.querySelectorAll(".katex-error").length, 0); +}); + +test("generic nested environments, comments, and bare row-leading command names are protected", () => { + for (const environment of ["matrix", "pmatrix", "bmatrix", "Bmatrix", "vmatrix", "Vmatrix", "smallmatrix", "array", "aligned", "cases"]) { + const source = `$\\begin{${environment}}a&b\\\\beta&d\\end{${environment}}$`; + assert.equal(repairMathForDisplay(source), source); + } + const nested = "$$\n" + String.raw`\begin{aligned}x&=\begin{pmatrix}a&b\\c&d\end{pmatrix}\\% \end{aligned} is a comment` + "\n" + String.raw`r&=s\end{aligned}` + "\n$$"; + assert.equal(repairMathForDisplay(nested), nested); + const spaced = String.raw`$\begin {pmatrix}a&b\\c&d\end {pmatrix}$`; + assert.equal(repairMathForDisplay(spaced), spaced); + const mixed = String.raw`$\\lambda+\begin{pmatrix}a&b\\c&d\end{pmatrix}$`; + assert.equal(repairMathForDisplay(mixed), mixed.replace(String.raw`\\lambda`, String.raw`\lambda`)); +}); + +test("corpus document framing and final-display percentages recover without new errors", () => { + const cases = JSON.parse(readFileSync(new URL("./fixtures/tool-rendering/document-percent-regressions.json", import.meta.url), "utf8")); + for (const fixture of cases) { + const recovered = repairMathForDisplay(fixture.raw); + const document = dom(render(fixture.raw)); + assert.equal(document.querySelectorAll(".katex-error").length, 0, fixture.chatId); + assert.equal(repairMathForDisplay(recovered), recovered, fixture.chatId); + if (fixture.kind === "percent-at-display-end") { + assert.ok([...document.querySelectorAll(".katex-html")].some(node => node.textContent?.includes("%")), fixture.chatId); + assert.ok(!recovered.includes("\\\\%"), fixture.chatId); + } else { + assert.match(recovered, /\n\$\$\n/); + assert.match(recovered, /\n\$\$$/); + } + } +}); +test("mixed-escaped environment markers retain existing recovery rather than treating an opener as native", () => { + const mixedFixtures = JSON.parse(readFileSync(new URL("./fixtures/tool-rendering/mixed-escaping-regressions.json", import.meta.url), "utf8")) as typeof fixtures; + assert.equal(mixedFixtures.cases.length, 2); + for (const fixture of mixedFixtures.cases) { + const recovered = repairMathForDisplay(fixture.raw); + assert.equal(repairMathForDisplay(recovered), recovered, fixture.chatId); + assert.doesNotMatch(recovered, /\\\\end\{/); + assert.equal(dom(render(fixture.raw)).querySelectorAll(".katex-error").length, 0); + } +}); + +test("presentation-equivalent labels cannot make a source-invalid correctAnswer gradeable", () => { + const source = { question: "Which?", options: [{ label: String.raw`$\nabla f$` }, { label: "no" }], + correctAnswer: doubled, explanation: "A model-authored identity mismatch" }; + const before = structuredClone(source); + assert.equal(repairMathForDisplay(doubled), source.options[0]!.label); + assert.equal(parseQuizInput(source), undefined); + assert.deepEqual(source, before); +}); + +test("shape parsers retain source text and identities; recovery is presentation only", () => { + const quiz = { ...input, question: doubled, explanation: "$\forall x$", options: [{ label: doubled }, { label: "no" }], correctAnswer: doubled }; + const ask = { question: doubled, options: [{ label: doubled, value: doubled, description: doubled }, { label: "no" }] }; + const explain = { question: doubled, correctAnswer: "$\forall x$" }; + const before = structuredClone({ quiz, ask, explain }); + assert.equal(parseQuizInput(quiz)!.question, doubled); + assert.equal(parseQuizInput(quiz)!.explanation, quiz.explanation); + assert.equal(parseQuizInput(quiz)!.correctAnswer, doubled); + assert.deepEqual(parseAskInput(ask), ask); + assert.deepEqual(parseExplainInput(explain), explain); + assert.deepEqual(tex(render(doubled)), [String.raw`\nabla f`]); + assert.deepEqual(tex(render(doubled, false)), [String.raw`\\nabla f`]); + assert.deepEqual({ quiz, ask, explain }, before); +}); + +test("the live dock recovers only its rendering, including question, context and option labels", () => { + const source = { question: doubled, details: doubled, options: [{ label: doubled }, { label: "no" }] }; + const before = structuredClone(source); + const html = renderToStaticMarkup( {}} onSubmit={() => {}} />); + assert.deepEqual(tex(html), Array(3).fill(String.raw`\nabla f`)); + assert.deepEqual(source, before); +}); + +test("answers snapshot source text, not recovered labels or identities", () => { + const source = { ...input, question: doubled, explanation: doubled, options: [{ label: doubled }, { label: "no" }], correctAnswer: doubled }; + const parsed = parseQuizInput(source)!; + const options = normalizeOptions(parsed.options); + const qd: QuestionData = { id: "source", kind: "quiz", question: parsed.question, mode: "single-select", options, + correctIndices: resolveCorrect(parsed.correctAnswer, options).indices, explanation: parsed.explanation, createdAt: "synthetic" }; + const answer = buildAnswer(qd, { values: [doubled], confidence: 70 }, "probe"); + assert.equal(answer.questionText, doubled); + assert.equal(answer.options![0]!.label, doubled); + assert.equal(answer.answers[0]!.value, doubled); + assert.equal(answer.answers[0]!.label, doubled); + assert.equal(answer.explanation, doubled); + assert.equal(answer.correct, true); + const before = structuredClone(answer); + assert.deepEqual(tex(renderToStaticMarkup()), Array(2).fill(String.raw`\nabla f`)); + assert.deepEqual(answer, before); +}); + +function historical(): QuestionAnswerData { + return { kind: "quiz", questionText: brokenA, options: [{ index: 1, label: brokenB }, { index: 2, label: brokenA }], + answers: [{ index: 1, label: brokenB, value: "b" }], correct: false, correctIndices: [2], dontKnow: false, + explanation: brokenA, confidence: 70, answeredAt: "synthetic" }; +} +test("historical source join restores matrices while retaining recorded order, answer and grade", () => { + const answer = historical(); + const before = structuredClone({ answer, input }); + const projection = answerForPresentation(answer, input); + assert.equal(projection.questionText, a); + assert.equal(projection.explanation, a); + assert.deepEqual(projection.options, [{ index: 1, label: b }, { index: 2, label: a }]); + assert.equal(projection.answers, answer.answers); + assert.equal(projection.correctIndices, answer.correctIndices); + assert.equal(projection.correct, false); + assert.equal(projection.confidence, 70); + const html = renderToStaticMarkup(); + assert.deepEqual(tex(html), [a.slice(1,-1), b.slice(1,-1), a.slice(1,-1)]); + assert.match(html, /incorrect/); + assert.deepEqual({ answer, input }, before); +}); + +test("historical ambiguous options and absent/invalid original calls retain their snapshots", () => { + const source = { ...input, options: [...input.options, { label: String.raw`$\begin{matrix}x\\y\end{matrix}$`, value: "c" }] }; + const answer = { ...historical(), answers: [], dontKnow: true, options: [ + { index: 1, label: brokenB }, { index: 2, label: brokenA }, { index: 3, label: String.raw`$\begin{matrix}x\y\end{matrix}$` }, + ] }; + assert.deepEqual(answerForPresentation(answer, source).options, answer.options); + assert.equal(answerForPresentation(answer, undefined), answer); + assert.equal(answerForPresentation(answer, { question: a }), answer); + const dismissed = { kind: "quiz", dismissed: true } as QuestionAnswerData; + assert.equal(answerForPresentation(dismissed, input), dismissed); + const before = structuredClone(answer); + renderToStaticMarkup(); + assert.deepEqual(answer, before); +}); diff --git a/tests/tool-parts.test.ts b/tests/tool-parts.test.ts index baccdfe..a1576f5 100644 --- a/tests/tool-parts.test.ts +++ b/tests/tool-parts.test.ts @@ -12,7 +12,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { quizInputProblems } from "../src/core/question-core.ts"; import { Value } from "typebox/value"; -import { restoreMathEscapes } from "../src/core/math-delims.ts"; +import { repairMathForDisplay, restoreMathEscapes } from "../src/core/math-delims.ts"; import { parseAskInput, parseProbeDoneInput, @@ -170,7 +170,7 @@ describe("LaTeX-escape restoration (the JSON-debris fix, owner ruling 2026-09-26 // The exact live shapes from the modal-logic session: single-backslash // LaTeX inside JSON args — JSON escape resolution ate the command's // first letter (\f → FF ate 'f' of \forall, \v → VT ate 'v' of \varphi). - it("parseQuizInput restores escape debris inside math spans (question + options + explanation)", () => { + it("presentation restores debris from parsed source fields (question + options + explanation)", () => { const parsed = parseQuizInput({ question: "What does \\(\\forall x\\, \\varphi(x)\\) mean?", options: [ @@ -182,18 +182,18 @@ describe("LaTeX-escape restoration (the JSON-debris fix, owner ruling 2026-09-26 }); assert.ok(parsed); assert.equal(parsed.question, "What does \\(\\forall x\\, \\varphi(x)\\) mean?"); - assert.equal(parsed.options[0]!.label, "\\(\\varphi\\) is true when every element satisfies it"); - assert.equal(parsed.options[1]!.label, "\\(\\forall\\) and \\(\\exists\\) are just shorthand"); - assert.equal(parsed.explanation, "\\(\\forall\\) quantifies over the domain."); + assert.equal(repairMathForDisplay(parsed.options[0]!.label), "\\(\\varphi\\) is true when every element satisfies it"); + assert.equal(repairMathForDisplay(parsed.options[1]!.label), "\\(\\forall\\) and \\(\\exists\\) are just shorthand"); + assert.equal(repairMathForDisplay(parsed.explanation), "\\(\\forall\\) quantifies over the domain."); }); - it("parseAskInput restores debris in ask option labels too", () => { + it("presentation restores debris from parsed ask option labels too", () => { const parsed = parseAskInput({ question: "Goal?", options: [{ label: "\\(\x0corall\\) rigor" }, { label: "Intuition first" }], }); assert.ok(parsed); - assert.equal(parsed.options![0]!.label, "\\(\\forall\\) rigor"); + assert.equal(repairMathForDisplay(parsed.options![0]!.label), "\\(\\forall\\) rigor"); }); it("restores TAB debris (the \\to case) inside spans only", () => { @@ -204,7 +204,7 @@ describe("LaTeX-escape restoration (the JSON-debris fix, owner ruling 2026-09-26 explanation: "", }); assert.ok(parsed); - assert.equal(parsed.question, "Is \\(p\\to q\\) valid?"); + assert.equal(repairMathForDisplay(parsed.question), "Is \\(p\\to q\\) valid?"); }); it("control chars OUTSIDE math spans are left alone (real prose/pasted text)", () => { @@ -253,7 +253,7 @@ describe("display-math newline rule (Option A, owner ruling 2026-09-27)", () => explanation: "", }); assert.ok(parsed); - assert.equal(parsed.question, "Evaluate \\[\\left( a \\right)\\] carefully."); + assert.equal(repairMathForDisplay(parsed.question), "Evaluate \\[\\left( a \\right)\\] carefully."); }); it("sloppy \\neq in display math stays broken — the accepted cost (newline kept, byte-identical to a real line break)", () => { @@ -276,7 +276,7 @@ describe("display-math newline rule (Option A, owner ruling 2026-09-27)", () => explanation: "", }); assert.ok(parsed); - assert.equal(parsed.question, "Is \\(p \\neq q\\) valid?"); + assert.equal(repairMathForDisplay(parsed.question), "Is \\(p \\neq q\\) valid?"); }); }); @@ -289,9 +289,9 @@ describe("dollar-delimiter recognition — existing escape restoration rules", ( explanation: "Here $p\to q$ is an implication.", }); assert.ok(parsed); - assert.equal(parsed.question, "What does $\\forall x$ mean?"); - assert.equal(parsed.options[0]!.label, "$\\varphi$ holds"); - assert.equal(parsed.explanation, "Here $p\\to q$ is an implication."); + assert.equal(repairMathForDisplay(parsed.question), "What does $\\forall x$ mean?"); + assert.equal(repairMathForDisplay(parsed.options[0]!.label), "$\\varphi$ holds"); + assert.equal(repairMathForDisplay(parsed.explanation), "Here $p\\to q$ is an implication."); }); it("keeps the same six-character inline recovery map", () => { @@ -320,21 +320,21 @@ describe("dollar-delimiter recognition — existing escape restoration rules", ( } }); - it("preserves legacy replacement frequency when a span has multiple debris characters", () => { + it("restores all debris in one pass consistently across delimiter syntaxes", () => { const body = "p\to q, \rho"; const legacy = restoreMathEscapes(`\\(${body}\\)`).slice(2, -2); assert.equal(restoreMathEscapes(`$${body}$`), `$${legacy}$`); - assert.equal(legacy, "p\\to q, \rho", "the existing first-match-only behavior is unchanged"); + assert.equal(legacy, "p\\to q, \\rho", "recovery converges instead of requiring a second render"); }); it("shares dollar recovery with ask and free-recall fields", () => { const ask = parseAskInput({ question: "Goal?", options: [{ label: "$\x0corall$ rigor" }, { label: "Intuition" }] }); assert.ok(ask); - assert.equal(ask.options![0]!.label, "$\\forall$ rigor"); + assert.equal(repairMathForDisplay(ask.options![0]!.label), "$\\forall$ rigor"); const explain = parseExplainInput({ question: "Explain $p\to q$", correctAnswer: "$p\neq q$ is different." }); assert.ok(explain); - assert.equal(explain.question, "Explain $p\\to q$"); - assert.equal(explain.correctAnswer, "$p\\neq q$ is different."); + assert.equal(repairMathForDisplay(explain.question), "Explain $p\\to q$"); + assert.equal(repairMathForDisplay(explain.correctAnswer), "$p\\neq q$ is different."); }); it("leaves non-math debris, escaped opening dollars, and code examples untouched", () => { @@ -401,13 +401,9 @@ describe("parseExplainInput: the dock's display seam for explain calls", () => { }); }); -describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)", () => { - // xiaomi/mimo-v2.6-pro and deepseek-v4.1-flash stochastically DOUBLE - // LaTeX command backslashes inside JSON tool arguments — per-call and - // per-field (gradient-descent msg 3: question doubled, explanation and - // options in the SAME call clean). restoreMathEscapesDeep now rides - // healOverescapedCommands after the debris restore, so doubled spans - // heal at every display parse: live dock, settled cards, old records. +describe("over-escaped command recovery after shape parsing", () => { + // Keep these real authoring shapes, but recover only for presentation. + // Parsed source is also used to build durable answers and identities. const CLEAN = "Let $f : \\mathbb{R}^n \\to \\mathbb{R}$ be differentiable at $x$, with $\\nabla f(x) \\neq 0$. Among all unit directions $\\|u\\|_2 = 1$, which makes $D_u f(x)$ most negative?"; const DOUBLE = (s: string): string => s.replace(/\\/g, "\\\\"); @@ -420,19 +416,21 @@ describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)" explanation: "By Cauchy\u2013Schwarz: $$u^\\star = -\\frac{\\nabla f(x)}{\\|\\nabla f(x)\\|_2}$$", }; - it("parseQuizInput heals a doubled question field (the gradient-descent msg-3 shape)", () => { + it("presentation heals a doubled question field (the gradient-descent msg-3 shape)", () => { const parsed = parseQuizInput({ ...BASE, question: DOUBLE(CLEAN) }); assert.ok(parsed, "parses"); - assert.equal(parsed.question, CLEAN); + assert.equal(parsed.question, DOUBLE(CLEAN), "shape parsing retains source"); + assert.equal(repairMathForDisplay(parsed.question), CLEAN); // The CLEAN sibling fields of the same call stay byte-identical. assert.equal(parsed.options[0]!.label, BASE.options[0]!.label); assert.equal(parsed.explanation, BASE.explanation); }); - it("parseQuizInput heals a quadrupled field (the agent's over-correction, msg 6)", () => { + it("presentation heals a quadrupled field (the agent's over-correction, msg 6)", () => { const parsed = parseQuizInput({ ...BASE, question: DOUBLE(DOUBLE(CLEAN)) }); assert.ok(parsed, "parses"); - assert.equal(parsed.question, CLEAN); + assert.equal(parsed.question, DOUBLE(DOUBLE(CLEAN)), "shape parsing retains source"); + assert.equal(repairMathForDisplay(parsed.question), CLEAN); }); it("doubled option labels and explanations heal too (the inference-runtime msg-7 shape)", () => { @@ -448,10 +446,10 @@ describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)" assert.ok(parsed, "parses"); assert.equal(parsed.options[0]!.label, "$mkn$"); assert.equal(parsed.options[1]!.label, "$2mkn$"); - assert.equal(parsed.explanation, "$$c_{ij} = \\sum_{\\ell=1}^{k} a_{i\\ell}\\, b_{\\ell j}$$"); + assert.equal(repairMathForDisplay(parsed.explanation), "$$c_{ij} = \\sum_{\\ell=1}^{k} a_{i\\ell}\\, b_{\\ell j}$$"); }); - it("parseAskInput heals doubled ask-option descriptions the same way", () => { + it("presentation heals doubled ask-option descriptions the same way", () => { const parsed = parseAskInput({ question: "Depth?", options: [ @@ -460,7 +458,7 @@ describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)" ], }); assert.ok(parsed, "parses"); - assert.equal(parsed.options![0]!.description, "why $-\\nabla f$ is steepest descent"); + assert.equal(repairMathForDisplay(parsed.options![0]!.description!), "why $-\\nabla f$ is steepest descent"); }); it("clean records pass through byte-identical (no over-heal)", () => { From 3dc88472eea41eed0d5e5f29c2f16f8d063c415b Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 26 Oct 2026 06:08:04 +0200 Subject: [PATCH 28/87] chore: prepare 0.3.8 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 15d4690..f2c5467 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "learnharness", - "version": "0.3.7", + "version": "0.3.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "learnharness", - "version": "0.3.7", + "version": "0.3.8", "license": "AGPL-3.0-only", "dependencies": { "@earendil-works/pi-agent-core": "1.4.0", diff --git a/package.json b/package.json index 5a9f30b..82e0fad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "learnharness", - "version": "0.3.7", + "version": "0.3.8", "license": "AGPL-3.0-only", "author": { "name": "Amos Blomqvist", From a57267590008960e3c3464b8865b14ed399cd29a Mon Sep 17 00:00:00 2001 From: Evangelos Stavropoulos Date: Mon, 26 Oct 2026 14:31:51 +0200 Subject: [PATCH 29/87] fix: let the learner retry a turn whose model call failed --- components/chat.tsx | 17 ++- src/core/input-events.ts | 49 +++++++ src/server/chat-handler.ts | 26 ++++ tests/retry-failed-turn.test.ts | 240 ++++++++++++++++++++++++++++++++ 4 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 tests/retry-failed-turn.test.ts diff --git a/components/chat.tsx b/components/chat.tsx index f8866f1..af6e585 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -65,9 +65,11 @@ import { type CompactionDividerData, } from "@/components/compaction-divider"; import { CoverageRing } from "@/components/extraction-coverage"; +import { Button } from "@/components/ui/button"; import { activeProblemOf, dismissedAnswer, + endsInFailedTurn, findPendingToolCall, phaseAt3, phaseTimeline, @@ -458,6 +460,12 @@ export function Chat({ const busy = localStream || turnInFlight; const busyRef = useRef(busy); busyRef.current = busy; + // A failed model call (issue #110): the banner offers Retry, which + // re-runs the turn from the saved input — the only path back, since an + // answered question cannot be answered again. Offered from "error" too: + // a dropped stream leaves the model there, and send() recovers from it. + const retryable = (status === "ready" || status === "error") && !busy && endsInFailedTurn(nativeMessages); + const retryFailedTurn = () => void send({ type: "retry" }); // The attach handoff (stream-reattach wave): the moment a resumed // stream takes over the rendering — the SDK left "ready", so @@ -1577,8 +1585,13 @@ export function Chat({ )} {error && ( -
- Something went wrong: {error.message} +
+ Something went wrong: {error.message} + {retryable && ( + + )}
)}
diff --git a/src/core/input-events.ts b/src/core/input-events.ts index 42d6985..24f368a 100644 --- a/src/core/input-events.ts +++ b/src/core/input-events.ts @@ -16,6 +16,9 @@ * continue — run the next turn on the current record (mount * continuations, job-settle re-syncs: the record ends at an * answered question with no teacher reaction yet). + * retry — re-run a turn whose model call failed (issue #110): the + * trailing failed assistant message(s) leave the record and + * the turn re-runs from the input that preceded them. * * (The plan verdict input died 2026-09-26 with the verdict itself — the * learner's post-plan reply is a natural text message.) @@ -45,6 +48,8 @@ export type ChatInput = | { type: "text"; text: string } | { type: "answer"; toolCallId: string; answer: QuestionAnswerData } | { type: "continue" } + /** The failed-turn retry (issue #110): the banner's Retry button. */ + | { type: "retry" } /** The practice dock's two buttons (owner, 2026-10-04): the learner's * thinking on the ACTIVE problem — one lane (owner 2026-10-05): * whether it claims an answer is read from the text. One @@ -170,6 +175,8 @@ export function parseChatInput(input: unknown): ChatInput { return { type: "answer", toolCallId: i.toolCallId, answer: i.answer as QuestionAnswerData }; case "continue": return { type: "continue" }; + case "retry": + return { type: "retry" }; case "thinking": if (typeof i.problemCallId !== "string" || i.problemCallId.length === 0) { throw new ChatInputError("thinking input requires problemCallId"); @@ -210,6 +217,10 @@ export function inputAppends( } case "continue": return dismissalConclusion(record); + case "retry": + // The failed turn's own input already landed write-ahead; the + // handler trims the failure (retryDrops) — nothing new appends. + return []; case "thinking": { const active = activeProblemOf(record.messages); if (!active || active.toolCallId !== input.problemCallId) { @@ -233,6 +244,44 @@ export function inputAppends( } } +/** Does the record end in a failed model call (a retryable turn)? */ +export function endsInFailedTurn(messages: readonly LhMessage[]): boolean { + return failedTail(messages).ids.size > 0; +} + +/** The ids a retry drops (issue #110, pure): the failed assistant + * message(s) ending the record. A provider failure persists as an + * assistant message with stopReason "error", and pi refuses to continue + * from an assistant — so neither re-answering (the question is no longer + * dangling) nor `continue` can resume the turn. Ids, not a cut index: + * UI-only markers may follow the failure, and they stay. Honest 400s + * when there is no failure to retry, or when dropping it leaves nothing + * to continue from. */ +export function retryDrops(messages: readonly LhMessage[]): Set { + const { ids, before } = failedTail(messages); + if (ids.size === 0) throw new ChatInputError("retry input requires a failed turn to retry"); + if (!before || before.role === "assistant") throw new ChatInputError("retry input leaves no input to continue from"); + return ids; +} + +/** Markers that can land AFTER a failed call and are never model input: + * persistTurn's plan block (plan_init succeeded, the next call failed) + * and the settle kick's compaction marker. */ +const TRAILING_MARKERS: readonly string[] = ["plan-block", "compaction"]; + +/** Walk back from the end over failed assistants and trailing markers: + * the failures' ids, and the message the run stops at. */ +function failedTail(messages: readonly LhMessage[]): { ids: Set; before: LhMessage | undefined } { + const ids = new Set(); + let i = messages.length - 1; + for (; i >= 0; i--) { + const m = messages[i]!; + if (m.role === "assistant" && m.stopReason === "error") ids.add(m.id); + else if (!(m.role === "custom" && TRAILING_MARKERS.includes(m.customType))) break; + } + return { ids, before: messages[i] }; +} + /** The deferred conclude synthesis (owner ruling, 2026-10-04): a * problem-dismissal marker waits for the user's next input — the pair * (synthetic conclude call + ack) rides ahead of it, so the agent reads diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index 8415688..2c55f8e 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -55,6 +55,7 @@ import { ChatInputError, inputAppends, parseChatInput, + retryDrops, type TurnInput, } from "../core/input-events.ts"; import { steerDelivered } from "../core/contracts.ts"; @@ -223,6 +224,18 @@ export async function handleChatInput( return Response.json(envelope, { status: 503 }); } + // THE RETRY GATE (issue #110): a retry with no failure to retry + // refuses here, BEFORE compaction and beginTurn — no effects. The + // trim itself re-validates on the record compaction re-reads below. + if (input.type === "retry") { + try { + retryDrops(record.messages); + } catch (err) { + if (err instanceof ChatInputError) return Response.json({ error: err.message }, { status: 400 }); + throw err; + } + } + // THE COMPACTION GATE (owner round 14, 2026-10-05 — the structural // ruling: NOTHING model-facing for this chat interleaves with a // running compaction): await any in-flight compaction (the settle @@ -261,6 +274,19 @@ export async function handleChatInput( const compactedRecord = stores.chatStore.getChat(chatId); if (!compactedRecord) return Response.json({ error: "Unknown chat" }, { status: 404 }); record = compactedRecord; + // THE RETRY TRIM: recomputed on the current record (the awaits above + // let other writers land), still before beginTurn. The trimmed record + // reaches disk through the write-ahead. + if (input.type === "retry") { + let drop: Set; + try { + drop = retryDrops(record.messages); + } catch (err) { + if (err instanceof ChatInputError) return Response.json({ error: err.message }, { status: 400 }); + throw err; + } + record = { ...record, messages: record.messages.filter((m) => !drop.has(m.id)) }; + } // The input's native appends (pure; the honest 400 for an answer with no // pending question call). Computed BEFORE beginTurn so a refused input diff --git a/tests/retry-failed-turn.test.ts b/tests/retry-failed-turn.test.ts new file mode 100644 index 0000000..dbd7507 --- /dev/null +++ b/tests/retry-failed-turn.test.ts @@ -0,0 +1,240 @@ +/** + * The failed-turn retry (issue #110): a model call that fails persists as an + * assistant message with stopReason "error". The learner's input survives + * (write-ahead), but pre-fix nothing could re-run the turn — re-answering + * 400'd (the question is no longer dangling) and `continue` died in pi + * ("Cannot continue from message role: assistant"). The `retry` input drops + * the failure and re-runs the turn from the input that preceded it. + * + * Drives handleChatRequest with a scripted pi engine (the chat-flow pattern). + */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { after, describe, it } from "node:test"; +import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai"; +import { SessionModel } from "../components/session-core.ts"; +import { createTeacherAgent } from "../src/agent/teacher-agent.ts"; +import { handleChatRequest } from "../src/server/chat-handler.ts"; +import { runningTurn } from "../src/server/turn-runs.ts"; +import { chatStore } from "../src/store/chats.ts"; +import { buildAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts"; +import { ChatInputError, endsInFailedTurn, parseChatInput, retryDrops } from "../src/core/input-events.ts"; +import { newPlanBlockMessage, newUserMessage, type LhMessage } from "../src/core/native-messages.ts"; +import { newCompactionMarker } from "../src/core/compaction.ts"; +import type { PiEngine } from "../src/agent/models.ts"; +import { PATHS } from "../config.ts"; + +process.env.OPENROUTER_API_KEY ??= "test-key-presence-gate"; + +const QUIZ_INPUT = { + question: "Which planet is closest to the sun?", + options: [{ label: "Venus" }, { label: "Mercury" }], + correctAnswer: "Mercury", + explanation: "Mercury is the innermost planet.", + type: "check", + nodeId: "planet-order", +}; + +/** A response step that fails the way a dropped connection does. */ +const networkFailure = (() => { + throw new Error("fetch failed"); +}) as unknown as FauxResponseStep; + +/** Scripted engine; each step records the conversation roles it saw + * (the composed system message aside). */ +function scripted(steps: FauxResponseStep[]): { engine: PiEngine; contexts: string[][] } { + const contexts: string[][] = []; + const faux = createFauxCore({}); + faux.setResponses( + steps.map((step) => ((context: { messages: Array<{ role: string }> }, ...rest: unknown[]) => { + contexts.push(context.messages.map((m) => m.role).filter((r) => r !== "system")); + return (step as (...a: unknown[]) => unknown)(context, ...rest); + }) as unknown as FauxResponseStep), + ); + return { engine: { model: faux.getModel(), streamFn: faux.streamSimple }, contexts }; +} + +function post(chatId: string, input: unknown): Request { + return new Request("http://test/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chatId, input }), + }); +} + +/** POST one input event; on a 200 ack, drain the turn to completion. */ +async function turn(engine: PiEngine, chatId: string, input: unknown): Promise { + const ack = await handleChatRequest(post(chatId, input), (opts) => + createTeacherAgent(undefined, { engine, messages: opts.messages }), + ); + if (ack.status === 200) { + const handle = runningTurn(chatId); + if (handle) await new Response(handle.subscribe()).text(); + } + return ack; +} + +const createdChats: string[] = []; +after(() => { + for (const id of createdChats) { + try { + fs.unlinkSync(path.join(PATHS.chats, `${id}.json`)); + } catch { + /* already gone */ + } + } +}); + +function createConfiguredChat(title: string) { + const chat = chatStore.createChat(title); + createdChats.push(chat.id); + chatStore.saveSettings(chat.id, { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" } }); + chatStore.saveTree(chat.id, { nodes: [{ id: "planet-order", title: "Planet order" }], edges: [], version: 1 }); + return chat; +} + +function gradedAnswer() { + const options = normalizeOptions(QUIZ_INPUT.options); + const qd: QuestionData = { + id: "call-1", + kind: "quiz", + question: QUIZ_INPUT.question, + mode: "single-select", + options, + correctIndices: resolveCorrect(QUIZ_INPUT.correctAnswer, options).indices, + explanation: QUIZ_INPUT.explanation, + createdAt: new Date().toISOString(), + }; + return buildAnswer(qd, { values: ["Mercury"] }); +} + +const roles = (chatId: string) => chatStore.getChat(chatId)!.messages.map((m) => m.role); + +describe("failed-turn retry (issue #110)", () => { + it("a failed answer turn re-runs from the saved answer — no re-answering needed", async () => { + const { engine, contexts } = scripted([ + () => fauxAssistantMessage([fauxToolCall("quiz", QUIZ_INPUT, { id: "call-1" })], { stopReason: "toolUse" }), + networkFailure, + () => fauxAssistantMessage([fauxText("Right — Mercury it is.")]), + ]); + const chat = createConfiguredChat("Test: retry after failed answer"); + + await turn(engine, chat.id, { type: "text", text: "teach me the planets" }); + await turn(engine, chat.id, { type: "answer", toolCallId: "call-1", answer: gradedAnswer() }); + + // The failure as #110 met it: answer durable, turn failed, and the + // pre-fix paths refused. + assert.deepEqual(roles(chat.id), ["user", "assistant", "toolResult", "assistant"]); + assert.ok(endsInFailedTurn(chatStore.getChat(chat.id)!.messages)); + const reanswer = await turn(engine, chat.id, { type: "answer", toolCallId: "call-1", answer: gradedAnswer() }); + assert.equal(reanswer.status, 400, "the answered question cannot be answered again"); + + const retry = await turn(engine, chat.id, { type: "retry" }); + assert.equal(retry.status, 200); + + const saved = chatStore.getChat(chat.id)!.messages; + assert.deepEqual(saved.map((m) => m.role), ["user", "assistant", "toolResult", "assistant"]); + assert.equal(endsInFailedTurn(saved), false, "the failure left the record"); + assert.match(JSON.stringify(saved.at(-1)), /Mercury it is/); + assert.deepEqual(contexts.at(-1), ["user", "assistant", "toolResult"], "the retried call saw the answer, not the failure"); + }); + + it("a failed text turn re-runs from the learner's message", async () => { + const { engine, contexts } = scripted([networkFailure, () => fauxAssistantMessage([fauxText("Hello again.")])]); + const chat = createConfiguredChat("Test: retry after failed text"); + + await turn(engine, chat.id, { type: "text", text: "hello" }); + assert.deepEqual(roles(chat.id), ["user", "assistant"]); + assert.ok(endsInFailedTurn(chatStore.getChat(chat.id)!.messages)); + + assert.equal((await turn(engine, chat.id, { type: "retry" })).status, 200); + assert.deepEqual(roles(chat.id), ["user", "assistant"]); + assert.match(JSON.stringify(chatStore.getChat(chat.id)!.messages.at(-1)), /Hello again/); + assert.deepEqual(contexts.at(-1), ["user"]); + }); + + it("refuses a retry with nothing failed — 400, record untouched", async () => { + const { engine } = scripted([() => fauxAssistantMessage([fauxText("Fine.")])]); + const chat = createConfiguredChat("Test: retry refused"); + + await turn(engine, chat.id, { type: "text", text: "hello" }); + const before = chatStore.getChat(chat.id)!.messages; + const res = await turn(engine, chat.id, { type: "retry" }); + assert.equal(res.status, 400); + assert.match(await res.text(), /requires a failed turn/); + assert.deepEqual(chatStore.getChat(chat.id)!.messages, before); + }); + + it("a plan block persisted after the failure stays, and the turn still retries", async () => { + const { engine, contexts } = scripted([networkFailure, () => fauxAssistantMessage([fauxText("Planned reply.")])]); + const chat = createConfiguredChat("Test: retry past a plan block"); + + await turn(engine, chat.id, { type: "text", text: "hello" }); + // persistTurn's shape when plan_init succeeded and the next call + // failed: the plan block lands AFTER the failure. + const failedRecord = chatStore.getChat(chat.id)!.messages; + chatStore.saveMessages(chat.id, [...failedRecord, newPlanBlockMessage("plan-1", {})]); + assert.ok(endsInFailedTurn(chatStore.getChat(chat.id)!.messages), "the failure is still found past the marker"); + + assert.equal((await turn(engine, chat.id, { type: "retry" })).status, 200); + const saved = chatStore.getChat(chat.id)!.messages; + assert.deepEqual(saved.map((m) => m.role), ["user", "custom", "assistant"], "failure dropped, plan block kept"); + assert.match(JSON.stringify(saved.at(-1)), /Planned reply/); + assert.deepEqual(contexts.at(-1), ["user"], "the plan block is not model input"); + }); +}); + +describe("the Retry button's client path", () => { + it("after a dropped request, Retry posts straight from the error status", async () => { + const failedTurn = [ + { ...newUserMessage("hello"), id: "u1" }, + { ...fauxAssistantMessage("", { stopReason: "error", errorMessage: "fetch failed" }), id: "a1" }, + ] as unknown as LhMessage[]; + const posted: string[] = []; + let online = false; + const fetch = (async (_url: string, init?: RequestInit) => { + if (!online) throw new TypeError("Failed to fetch"); + if (init?.method === "POST") posted.push(String(init.body)); + return new Response("{}", { status: 500 }); // the reattach is not under test + }) as typeof globalThis.fetch; + const m = new SessionModel({ chatId: "chat_x", initialMessages: failedTurn, fetch }); + + await m.send({ type: "retry" }); + assert.equal(m.getState().status, "error", "the dropped request leaves the model in error"); + online = true; + await m.send({ type: "retry" }); + assert.equal(posted.length, 1, "send() recovers from error without stop()"); + assert.deepEqual(JSON.parse(posted[0]!).input, { type: "retry" }); + }); +}); + +describe("retryDrops (pure)", () => { + const failed = (id: string): LhMessage => + ({ ...fauxAssistantMessage("", { stopReason: "error", errorMessage: "boom" }), id }) as unknown as LhMessage; + + it("parses the retry input event", () => { + assert.deepEqual(parseChatInput({ type: "retry" }), { type: "retry" }); + }); + + it("drops every trailing failure back to the input", () => { + const user = { ...newUserMessage("hi"), id: "u1" } as LhMessage; + assert.deepEqual([...retryDrops([user, failed("a1"), failed("a2")])].sort(), ["a1", "a2"]); + }); + + it("looks past trailing UI-only markers, and keeps them", () => { + const user = { ...newUserMessage("hi"), id: "u1" } as LhMessage; + const block = newPlanBlockMessage("plan-1", {}); + assert.deepEqual([...retryDrops([user, failed("a1"), block])], ["a1"]); + assert.equal(endsInFailedTurn([user, block]), false, "a marker alone is no failure"); + const compaction = newCompactionMarker("summary", { firstKeptMessageId: "u1", tokensBefore: 100, model: "fixture", at: "2026-01-01T00:00:00Z" }); + assert.deepEqual([...retryDrops([user, failed("a1"), compaction])], ["a1"], "the settle kick's marker too"); + }); + + it("throws when the record does not end in a failure, or nothing precedes it", () => { + const user = { ...newUserMessage("hi"), id: "u1" } as LhMessage; + assert.throws(() => retryDrops([user]), ChatInputError); + assert.throws(() => retryDrops([failed("a1")]), ChatInputError); + }); +}); From 88ba42fd3719ec9d23f5fc97de72653536ed112e Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 26 Oct 2026 18:50:54 +0200 Subject: [PATCH 30/87] fix: allow repairing model slots after subscription disconnect --- app/api/settings/[section]/route.ts | 14 ++- src/server/model-catalog.ts | 14 ++- tests/models-settings-repair.test.ts | 165 +++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 tests/models-settings-repair.test.ts diff --git a/app/api/settings/[section]/route.ts b/app/api/settings/[section]/route.ts index 616db9f..a51da1c 100644 --- a/app/api/settings/[section]/route.ts +++ b/app/api/settings/[section]/route.ts @@ -10,7 +10,8 @@ * (server/settings-body.ts): a bad shape/type is a 400 carrying the * supported list where one exists. The chat and models sections * additionally pass the catalog gate (validateChatSettings / - * validateModelsSettings — unknown provider/model = 400). Thinking + * validateModelsSettings — unknown provider/model = 400, except unchanged + * model-slot selections may remain saved while unavailable). Thinking * levels never reject a write (owner, interlude 2026-09-22): a pinned * level CLAMPS to the named model's nearest supported level and the * clamped value is stored — model swaps just work, same as the @@ -63,6 +64,8 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st if (!parsed.ok) { return Response.json({ error: parsed.error }, { status: 400 }); } + // Compare against the same record that this whole-section write replaces. + const record = readSettingsRecord() ?? { version: 1 }; let agentHealed: AgentSettings | undefined; // Section-specific cross-validation (against the catalog / the prompt @@ -73,11 +76,11 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 }); } } - // Model slots wave (2026-09-20): the models section gets the same - // catalog gate the chat section gets — a slot must name a real - // provider+model and a thinking level it supports. + // New/replaced slot models pass the catalog gate. Unchanged saved model + // references may survive disconnect, including thinking-preference edits, + // so each unavailable slot can be repaired independently of the others. if (section === "models") { - const verdict = validateModelsSettings(parsed.settings as ModelsSettings); + const verdict = validateModelsSettings(parsed.settings as ModelsSettings, record.models); if (!verdict.ok) { return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 }); } @@ -124,7 +127,6 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st // Read-modify-write ONE section of the record; everything else rides // along untouched (other sections, unknown keys — sanitize preserves). - const record = readSettingsRecord() ?? { version: 1 }; const next = { ...record, [section]: sectionOut }; writeSettingsRecord(next); return Response.json({ settings: next }); diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index 0aa0b2a..11fbe0f 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -245,10 +245,17 @@ export function validateChatSettings(settings: ChatSettings): SettingsValidation return { ok: true }; } -export function validateModelsSettings(settings: ModelsSettings): SettingsValidation { +/** Validate introduced/replaced model selections, not unchanged saved pins. + * A disconnected subscription loses its available catalog, but its stored + * selections must not prevent repairing another slot or editing thinking. + * Only the SAME slot's provider+id can pass through; runtime admission stays + * separate. Callers without a stored section retain strict catalog checking. */ +export function validateModelsSettings(settings: ModelsSettings, stored?: ModelsSettings): SettingsValidation { for (const name of MODEL_SLOTS) { const slot = settings[name]; if (!slot) continue; + const previous = stored?.[name]?.model; + if (slot.model && previous && slot.model.provider === previous.provider && slot.model.id === previous.id) continue; const verdict = validateSlotSpec(name, slot); if (!verdict.ok) return verdict; } @@ -280,9 +287,8 @@ function validateSlotSpec(name: ModelSlotName, slot: { model?: { provider: strin // runtime — and the clamped value is what lands in the record. The // server is the truth: the record shows the clamped level after write. -/** Clamp one level against one model (identity when the model is unknown - * or the level already supported — the existence gate rejects unknown - * models separately, so this only ever sees real ones in practice). */ +/** Clamp one level against one model. Preserve the preference when metadata + * is unavailable for an unchanged saved pin; runtime clamps after reconnect. */ function clampLevelFor(provider: string, id: string, level: ThinkingLevel): ThinkingLevel { const model = connectionModel(provider, id); return model ? clampThinkingLevel(model, level) : level; diff --git a/tests/models-settings-repair.test.ts b/tests/models-settings-repair.test.ts new file mode 100644 index 0000000..224e08d --- /dev/null +++ b/tests/models-settings-repair.test.ts @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { after, beforeEach, test } from "node:test"; +import type { ModelsSettings, ModelSlotName } from "../src/core/settings.ts"; +import type { NativeSubscriptionSnapshot } from "../src/core/contracts/subscriptions.ts"; + +// Every app import follows isolation. No installed credentials, ambient keys, +// OAuth, or provider requests: subscription state comes from synthetic IPC. +const directory = fs.mkdtempSync(path.join(os.tmpdir(), "lh-models-repair-")); +process.env.LH_DATA_DIR = directory; +process.env.LH_AUTH_FILE = path.join(directory, "auth.json"); +process.env.LH_IGNORE_ENV_KEYS = "1"; +fs.writeFileSync(process.env.LH_AUTH_FILE, "{}"); +const originalSend = Object.getOwnPropertyDescriptor(process, "send"); +const originalConnected = Object.getOwnPropertyDescriptor(process, "connected"); +const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET; +process.env.LH_DESKTOP_SERVER = "1"; +process.env.LH_DESKTOP_API_SECRET = "d".repeat(64); +Object.defineProperty(process, "connected", { configurable: true, value: true }); + +const { piModels, piEngineForSpec } = await import("../src/agent/models.ts"); +const { refreshSubscriptionModels } = await import("../src/agent/subscription-bridge.ts"); +const { modelCatalog, validateModelsSettings } = await import("../src/server/model-catalog.ts"); +const { readSettings: readSettingsRecord, writeSettings: writeSettingsRecord } = await import("../src/store/settings.ts"); +const { PUT } = await import("../app/api/settings/[section]/route.ts"); +const { clampThinkingLevel, normalizeContext } = await import("@earendil-works/pi-ai"); +const native = piModels().getModels("github-copilot")[0]!; +const copilot = { provider: "github-copilot-subscription", id: native.id }; +const replacement = { provider: "openrouter", id: "z-ai/glm-5.3-flash" }; +const slots: ModelSlotName[] = ["strong", "cheap", "vision"]; +const initial: ModelsSettings = Object.fromEntries(slots.map((slot) => [slot, { model: copilot, thinkingLevel: "high" }])); +let connected = false, revision = 0, leases = 0; +function snapshot(): NativeSubscriptionSnapshot { + return { + statuses: [{ providerId: "github-copilot", available: true, connected, pending: false, allowBackgroundUse: true, connectionId: connected ? "fixture-connection" : null, epoch: revision, authorityRevision: revision, revision }], + models: { "github-copilot": connected ? [native] : [] }, + }; +} +Object.defineProperty(process, "send", { configurable: true, value: (message: { id: string; op: string }, callback?: () => void) => { + if (message.op !== "snapshot") { leases++; throw new Error("No credential lease expected"); } + queueMicrotask(() => (process.emit as (event: string, value: unknown) => boolean)("message", { channel: "lh:subscriptions", id: message.id, op: message.op, ok: true, value: snapshot() })); + callback?.(); return true; +} }); +async function connection(value: boolean) { connected = value; revision++; await refreshSubscriptionModels(); } +async function put(body: unknown) { + return PUT(new Request("http://localhost/api/settings/models", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }), { params: Promise.resolve({ section: "models" }) }); +} +beforeEach(async () => { + await connection(false); + writeSettingsRecord({ version: 1, models: initial, general: { autoUpdate: false }, graph: { rendering: false } }); +}); +after(() => { + if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send; + if (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected; + for (const [key, value] of [["LH_DESKTOP_SERVER", marker], ["LH_DESKTOP_API_SECRET", secret]]) { + if (value === undefined) delete process.env[key!]; else process.env[key!] = value; + } + fs.rmSync(directory, { recursive: true, force: true }); +}); + +for (const slot of slots) test(`${slot} can be replaced while the other disconnected slots remain saved`, async () => { + const response = await put({ ...initial, [slot]: { model: replacement, thinkingLevel: "xhigh" } }); + assert.equal(response.status, 200, JSON.stringify(await response.clone().json())); + const stored = readSettingsRecord()!; + assert.deepEqual(stored.models?.[slot], { model: replacement, thinkingLevel: "max" }); + for (const other of slots.filter((name) => name !== slot)) assert.deepEqual(stored.models?.[other], initial[other]); + assert.deepEqual(stored.general, { autoUpdate: false }); + assert.deepEqual(stored.graph, { rendering: false }); + assert.deepEqual((await response.json()).settings, stored); + assert(!modelCatalog().models.some((model) => model.provider === copilot.provider), "saving stale pins must not make them available choices"); +}); + +test("all three slots can be repaired successively without reconnecting", async () => { + for (const slot of slots) { + const response = await put({ ...readSettingsRecord()!.models, [slot]: { model: replacement } }); + assert.equal(response.status, 200); + } + assert.deepEqual(readSettingsRecord()!.models, Object.fromEntries(slots.map((slot) => [slot, { model: replacement }]))); +}); + +test("thinking preferences on unchanged disconnected models can be changed, removed, and restored", async () => { + for (const thinkingLevel of ["max", undefined, "minimal"] as const) { + const strong = { model: copilot, ...(thinkingLevel ? { thinkingLevel } : {}) }; + const response = await put({ ...initial, strong }); + assert.equal(response.status, 200); + assert.deepEqual(readSettingsRecord()!.models?.strong, strong); + } + await connection(true); + const engine = piEngineForSpec({ ...copilot, thinkingLevel: "minimal" }, false); + assert.equal(engine.level, clampThinkingLevel(native, "minimal"), "reconnected runtime clamps the saved preference"); + assert.equal((await put(readSettingsRecord()!.models)).status, 200); + assert.equal(readSettingsRecord()!.models?.strong?.thinkingLevel, engine.level, "known metadata still clamps on write"); +}); + +test("introduced or replaced unknown models still reject, without writing any section", async () => { + for (const model of [ + { provider: copilot.provider, id: "fixture-not-in-catalog" }, + { provider: "xai-subscription", id: copilot.id }, + { provider: "fixture-unknown-provider", id: copilot.id }, + { provider: replacement.provider, id: "fixture-not-in-catalog" }, + ]) { + const before = readSettingsRecord(); + assert.equal((await put({ ...initial, strong: { model } })).status, 400); + assert.deepEqual(readSettingsRecord(), before); + } + writeSettingsRecord({ version: 1, models: { strong: initial.strong } }); + assert.equal((await put({ strong: initial.strong, cheap: initial.strong })).status, 400, "a pin saved in one slot cannot authorize another slot"); + assert.equal((await put(initial)).status, 400, "missing slots cannot introduce disconnected models"); +}); + +test("a first models write cannot introduce disconnected selections", async () => { + fs.unlinkSync(path.join(directory, "settings.json")); + assert.equal((await put(initial)).status, 400); + assert.equal(readSettingsRecord(), null); +}); + +test("a replaced stale selection cannot be reintroduced without availability", async () => { + assert.equal((await put({ ...initial, strong: { model: replacement } })).status, 200); + assert.equal((await put(initial)).status, 400); + assert.deepEqual(readSettingsRecord()!.models?.strong?.model, replacement); +}); + +test("slot removal still replaces the whole section, including clearing all slots", async () => { + assert.equal((await put({ cheap: initial.cheap })).status, 200); + assert.deepEqual(readSettingsRecord()!.models, { cheap: initial.cheap }); + assert.equal((await put({})).status, 200); + assert.deepEqual(readSettingsRecord()!.models, {}); +}); + +test("unchanged model identity does not bypass body shape validation", async () => { + for (const body of [ + { ...initial, strong: { model: copilot, thinkingLevel: 7 } }, + { ...initial, strong: { thinkingLevel: "high" } }, + { ...initial, strong: { model: { provider: copilot.provider } } }, + ]) assert.equal((await put(body)).status, 400); + assert.deepEqual(readSettingsRecord()!.models, initial); +}); + +test("standalone catalog validation stays strict without a stored section", () => { + assert.equal(validateModelsSettings(initial).ok, false); + assert.equal(validateModelsSettings({ strong: { model: replacement } }).ok, true); +}); + +test("retained selections remain unavailable at runtime, with no API-key fallback or credential lease", async () => { + await connection(true); + const engine = piEngineForSpec(copilot, false); + await connection(false); + assert.equal((await put({ ...initial, cheap: { model: replacement } })).status, 200); + assert.throws(() => piEngineForSpec(copilot, false), /catalog has no model/); + let requests = 0; + const before = leases; + const key = process.env.COPILOT_GITHUB_TOKEN; + process.env.COPILOT_GITHUB_TOKEN = "fixture-must-not-be-used"; + try { + const result = await engine.streamFn(engine.model, normalizeContext({ messages: [] }), { fetch: async () => { requests++; throw new Error("Must not dispatch"); } }).result(); + assert.equal(result.stopReason, "error"); + assert.match(result.errorMessage ?? "", /No API-key fallback was used/); + assert.equal(requests, 0); + assert.equal(leases, before); + } finally { + if (key === undefined) delete process.env.COPILOT_GITHUB_TOKEN; else process.env.COPILOT_GITHUB_TOKEN = key; + } +}); From b81f0a3455dc514fac2a5b6e2e7d7de850fb5b4f Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 26 Oct 2026 19:08:15 +0200 Subject: [PATCH 31/87] fix: avoid replaying superseded compaction summaries --- docs/compaction-accounting.md | 11 +++ src/core/compaction.ts | 6 +- src/core/native-messages.ts | 14 ++-- src/server/compaction.ts | 17 ++++- tests/compaction-service.test.ts | 112 ++++++++++++++++++++++++++++++- tests/compaction.test.ts | 44 ++++++++++++ 6 files changed, 191 insertions(+), 13 deletions(-) diff --git a/docs/compaction-accounting.md b/docs/compaction-accounting.md index f3b20d6..b2abcdc 100644 --- a/docs/compaction-accounting.md +++ b/docs/compaction-accounting.md @@ -24,6 +24,17 @@ prompt, plan continuity, persistence, threshold, scheduling, and authorization lifecycle. It does not adopt Pi's coding summary or separate split-turn summary calls. A cut that drops no active history does not launch a summarizer. +When the active cutoff is applied, conversation requests carry the latest summary +once, followed by the retained transcript without older compaction markers. When +a subsequent compaction supplies that prior summary separately, it includes it +once, plus the newly superseded transcript without summary markers. All markers +and original messages remain stored. The existing full-history fallback for an +unresolved cutoff is unchanged, including its summary text. + +A provider response stopped at its output limit (`length`) is an incomplete +summary, not a successful checkpoint. Compaction fails without changing the +existing history or prior summary; a later attempt can retry normally. + ## Conversation meter The numerator is the latest qualifying completed conversation response's native diff --git a/src/core/compaction.ts b/src/core/compaction.ts index 8e3f768..4ca0fab 100644 --- a/src/core/compaction.ts +++ b/src/core/compaction.ts @@ -272,9 +272,9 @@ export function newCompactionMarker(summary: string, details: CompactionDetails) }; } -/** The ACTIVE compaction marker: the newest one in the record (chained - * compactions — the last one rules; older markers fall inside the - * dropped span and never render). */ +/** The ACTIVE compaction marker: the newest one with a retained-message + * id. Its summary supersedes older markers even when their stored + * positions remain inside its retained tail. */ export function activeCompactionOf(messages: readonly LhMessage[]): (LhMarkerMessage & { details: CompactionDetails }) | null { for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]!; diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index 63f2720..80f9872 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -562,8 +562,9 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] { // THE COMPACTION CUT (owner round 14, 2026-10-05): the ACTIVE marker // (the newest — chained compactions rule last) names the first kept // message; everything before it is replaced by the marker's content - // (header + summary) rendered ONCE, up top. The marker itself is then - // skipped in place — it must never render twice. A cut naming a + // (header + summary) rendered ONCE, up top. All compaction markers + // in the retained tail are then skipped, including older summaries + // whose stored positions still lie after the cut. A cut naming a // missing id degrades honestly (everything renders — never throw). const lhMessages = messages as readonly LhMessage[]; const active = activeCompactionOf(lhMessages); @@ -604,11 +605,10 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] { // UI-only markers: plan blocks, and problem dismissals (the // synthesized conclude pair carries the model's face). if (marker.customType === "plan-block" || marker.customType === "problem-dismissed") continue; - // The ACTIVE compaction marker already rendered as the head — - // never twice. (Stale markers before the cut are unreachable; a - // marker whose cut could not resolve has no head and renders as - // its user text — honest degradation.) - if (marker.customType === "compaction" && head !== null && marker.id === active!.id) continue; + // The head supersedes every compaction marker, not only its own + // stored position. Without a resolved head, preserve the existing + // full-history fallback, including the summary text. + if (marker.customType === "compaction" && head !== null) continue; out.push({ role: "user", content: [{ type: "text", text: marker.content }], diff --git a/src/server/compaction.ts b/src/server/compaction.ts index cc62aa6..865f9ca 100644 --- a/src/server/compaction.ts +++ b/src/server/compaction.ts @@ -69,7 +69,7 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; - const { text } = await runSubagent([], { + const { text, newMessages } = await runSubagent([], { // The engine override is always present (the chat's own conversation // engine — owner ruling), so the role label never resolves an // engine; "researcher" is the least-wrong existing ModelRole label @@ -83,6 +83,12 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s usage = u; }, }); + // A provider-declared incomplete response is not a usable checkpoint, + // even when it contains nonempty text. Match Pi's compaction failure + // semantics: leave the existing history and prior summary untouched. + if (newMessages.some((message) => message.role === "assistant" && message.stopReason === "length")) { + throw new Error("Compaction summary hit the output limit and is incomplete"); + } return { summary: text, usage }; } @@ -201,7 +207,14 @@ async function compact(deps: CompactionDeps, chatId: string): Promise { spanStart = prevKept; priorSummary = previous.content; } - const spanText = serializeForSummary(messages.slice(spanStart, cut)); + const span = messages.slice(spanStart, cut); + // The separately supplied prior summary replaces all summary markers + // in this span. Without a resolved prior boundary, retain the existing + // legacy fallback rather than silently discarding summary-only context. + const summaryMessages = priorSummary + ? span.filter((m) => m.role !== "custom" || m.customType !== "compaction") + : span; + const spanText = serializeForSummary(summaryMessages); const fullSpan = priorSummary ? `[SYSTEM NOTE — prior compaction summary, still authoritative where not superseded]\n${priorSummary}\n\n${spanText}` : spanText; const prompt = buildCompactionPrompt({ diff --git a/tests/compaction-service.test.ts b/tests/compaction-service.test.ts index ee5b4d4..9a27a66 100644 --- a/tests/compaction-service.test.ts +++ b/tests/compaction-service.test.ts @@ -27,12 +27,14 @@ const { ensureCompacted, kickCompaction, awaitCompaction, + compactionInFlight, effectiveThresholdTokens, projectedContextTokens, } = await import("../src/server/compaction.ts"); import type { CompactionDeps } from "../src/server/compaction.ts"; +import { createFauxCore, fauxAssistantMessage } from "@earendil-works/pi-ai"; const { subscribe } = await import("../src/core/events.ts"); -const { activeCompactionOf, newUserMessage, measuredContextTokens, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts"); +const { activeCompactionOf, newCompactionMarker, newUserMessage, measuredContextTokens, toLlmMessages, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts"); import type { LhMessage } from "../src/core/index.ts"; after(() => { @@ -167,12 +169,120 @@ describe("the compaction service", () => { await ensureCompacted(deps, chatId); assert.ok(seenPrompt.includes("prior compaction summary"), "the prior summary rides the span"); assert.ok(seenPrompt.includes(COMPACTED_HISTORY_HEADER), "as the marker's canonical text"); + assert.equal(seenPrompt.split(COMPACTED_HISTORY_HEADER).length - 1, 1, "the prior summary is supplied exactly once"); const record = chatStore.getChat(chatId)!; const markers = record.messages.filter((m) => (m as { customType?: string }).customType === "compaction"); assert.equal(markers.length, 2, "both markers persist (history is never rewritten)"); assert.ok(activeCompactionOf(record.messages)!.content.includes("SECOND SUMMARY"), "the newest rules"); }); + it("ten successive compactions keep one summary on the wire and preserve the complete record", async () => { + const chat = chatStore.createChat("repeated-cycle fixture"); + chatStore.saveMessages(chat.id, Array.from({ length: 20 }, (_, i) => big(`exchange-${i}`, 8))); + let calls = 0; + const deps: CompactionDeps = { + ...depsOf(chat.id, 150_000), + summarize: async (prompt) => { + assert.equal(prompt.split(COMPACTED_HISTORY_HEADER).length - 1, calls === 0 ? 0 : 1); + if (calls > 0) assert.ok(prompt.includes(`SUMMARY-${calls}.`), "only the immediately prior summary rides forward"); + return { + summary: `SUMMARY-${++calls}.`, + usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + }; + }, + }; + for (let cycle = 1; cycle <= 10; cycle++) { + const messages = [...chatStore.getChat(chat.id)!.messages]; + if (cycle > 1) messages.push(big(`growth-${cycle}`, 8)); + // Controlled provider readings trigger compaction while the visible + // retained tail advances slowly enough to contain older markers. + messages.push({ id: `usage-${cycle}`, role: "assistant", content: [{ type: "text", text: "measured response" }], stopReason: "stop", usage: { input: 150_001, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 150_002 }, timestamp: cycle } as LhMessage); + chatStore.saveMessages(chat.id, messages); + await ensureCompacted(deps, chat.id); + assert.equal(calls, cycle); + const record = chatStore.getChat(chat.id)!; + assert.deepEqual(record.messages.slice(0, -1), messages, "every original message and marker remains stored"); + assert.equal(record.messages.filter((m) => m.role === "custom" && m.customType === "compaction").length, cycle); + const wire = JSON.stringify(toLlmMessages(record.messages)); + assert.equal(wire.split(COMPACTED_HISTORY_HEADER).length - 1, 1, `cycle ${cycle}: one summary on the wire`); + assert.ok(wire.includes(`SUMMARY-${cycle}.`)); + assert.equal(measuredContextTokens(record.messages), null, "committing a marker invalidates pre-compaction readings"); + } + }); + + it("a resolved prior summary replaces all summary markers in the newly superseded span", async () => { + const chat = chatStore.createChat("summary span fixture"); + const kept = big("previously retained"); + const details = { firstKeptMessageId: kept.id, tokensBefore: 150_000, at: "fixture" }; + const steer: LhMessage = { id: "steer", role: "custom", customType: "subagent-steer", content: "RESEARCH DELIVERY", display: true, details: {}, timestamp: 1 }; + const messages = [big("discarded"), kept, newCompactionMarker("SUPERSEDED SUMMARY", details), steer, newCompactionMarker("LATEST SUMMARY", details), big("growth"), big("recent")]; + chatStore.saveMessages(chat.id, messages); + let seenPrompt = ""; + await ensureCompacted({ ...depsOf(chat.id, 40_000), summarize: async (prompt, engine) => { + seenPrompt = prompt; + return depsOf(chat.id, 40_000).summarize!(prompt, engine); + } }, chat.id); + assert.ok(seenPrompt.includes("previously retained")); + assert.ok(seenPrompt.includes("RESEARCH DELIVERY"), "non-compaction context markers still ride the span"); + assert.ok(!seenPrompt.includes("SUPERSEDED SUMMARY")); + assert.equal(seenPrompt.split("LATEST SUMMARY").length - 1, 1); + assert.equal(seenPrompt.split(COMPACTED_HISTORY_HEADER).length - 1, 1); + assert.deepEqual(chatStore.getChat(chat.id)!.messages.slice(0, -1), messages); + }); + + it("an unresolved cutoff preserves legacy summary text in the next compaction input", async () => { + const chat = chatStore.createChat("legacy summary fixture"); + const legacy = newCompactionMarker("LEGACY-ONLY INFORMATION", { firstKeptMessageId: "missing", tokensBefore: 150_000, at: "fixture" }); + const messages = [big("old"), legacy, big("recent"), newUserMessage("latest")]; + chatStore.saveMessages(chat.id, messages); + let seenPrompt = ""; + await ensureCompacted({ ...depsOf(chat.id, 40_000), summarize: async (prompt, engine) => { + seenPrompt = prompt; + return depsOf(chat.id, 40_000).summarize!(prompt, engine); + } }, chat.id); + assert.equal(seenPrompt.split("LEGACY-ONLY INFORMATION").length - 1, 1); + assert.ok(!seenPrompt.includes("prior compaction summary"), "no separate prior summary exists for an unresolved cutoff"); + assert.deepEqual(chatStore.getChat(chat.id)!.messages.slice(0, -1), messages); + }); + + it("the production summarizer rejects a length-stopped checkpoint and a complete retry can commit", async () => { + const chatId = overThresholdChat("incomplete summary fixture"); + await ensureCompacted(depsOf(chatId, 40_000), chatId); + chatStore.saveMessages(chatId, [...chatStore.getChat(chatId)!.messages, big("growth 1"), big("growth 2")]); + const faux = createFauxCore({}); + const { summarize: _injected, ...base } = depsOf(chatId, 40_000); + const deps: CompactionDeps = { ...base, engine: { model: faux.getModel(), streamFn: faux.streamSimple } }; + const before = chatStore.getChat(chatId)!; + assert.ok(activeCompactionOf(before.messages), "the failed replacement must preserve an existing checkpoint too"); + const events: string[] = []; + const off = subscribe((event) => { + const { chatId: id, running } = event as { chatId?: string; running?: boolean }; + if (event.type === "compaction-changed" && id === chatId) events.push(`${running}`); + }); + // Exercise the actual zero-tool summarizer with an offline provider; + // NODE_ENV=test otherwise deliberately skips non-injected summaries. + const env: Record = process.env; + const originalNodeEnv = env.NODE_ENV; + env.NODE_ENV = "development"; + try { + faux.setResponses([fauxAssistantMessage("INCOMPLETE SUMMARY", { stopReason: "length" })]); + await ensureCompacted(deps, chatId); + assert.deepEqual(chatStore.getChat(chatId), before, "an explicitly unfinished summary must not become a checkpoint"); + assert.equal(compactionInFlight(chatId), false, "the failed run releases its gate"); + assert.deepEqual(events, ["true", "false"]); + faux.setResponses([fauxAssistantMessage("COMPLETE SUMMARY")]); + await ensureCompacted(deps, chatId); + const record = chatStore.getChat(chatId)!; + assert.ok(activeCompactionOf(record.messages)!.content.includes("COMPLETE SUMMARY")); + assert.deepEqual(record.messages.slice(0, -1), before.messages); + assert.ok(!JSON.stringify(record.messages).includes("INCOMPLETE SUMMARY")); + } finally { + if (originalNodeEnv === undefined) delete env.NODE_ENV; + else env.NODE_ENV = originalNodeEnv; + off(); + } + }); + it("effectiveThresholdTokens: the setting rules big windows; the window rule binds small ones", () => { assert.equal(effectiveThresholdTokens(150_000, 200_000), 150_000); assert.equal(effectiveThresholdTokens(150_000, 100_000), 100_000 - 16_384); diff --git a/tests/compaction.test.ts b/tests/compaction.test.ts index 5139558..954ea5f 100644 --- a/tests/compaction.test.ts +++ b/tests/compaction.test.ts @@ -256,6 +256,22 @@ describe("toLlmMessages honors the cut", () => { assert.equal(JSON.stringify(out).indexOf("COMPACTED]"), JSON.stringify(out).lastIndexOf("COMPACTED]"), "header appears exactly once"); }); + it("omits nine superseded summaries inside the retained tail without changing stored history", () => { + const kept = newUserMessage("retained exchange"); + const details = { firstKeptMessageId: kept.id, tokensBefore: 200_000, at: "fixture" }; + const old = Array.from({ length: 9 }, (_, i) => newCompactionMarker(`SUPERSEDED-${i}`, details)); + const fresh = newCompactionMarker("LATEST SUMMARY", details); + const messages = [newUserMessage("discarded"), kept, ...old, newUserMessage("latest reply"), fresh]; + const original = JSON.stringify(messages); + const out = toLlmMessages(messages); + const flat = JSON.stringify(out); + assert.equal(out.length, 3, "latest summary + retained exchange + latest reply"); + assert.ok(flat.includes("LATEST SUMMARY")); + assert.ok(!flat.includes("SUPERSEDED-")); + assert.equal(flat.split(COMPACTED_HISTORY_HEADER).length - 1, 1); + assert.equal(JSON.stringify(messages), original, "projection never rewrites the record"); + }); + it("a pending (dangling) question call in the KEPT tail still gets the interrupted synthesis", () => { const kept = [assistantWithCall("a1", "call_1")]; const marker = newCompactionMarker("S", { firstKeptMessageId: "a1", tokensBefore: 1, at: "t" }); @@ -270,6 +286,22 @@ describe("toLlmMessages honors the cut", () => { assert.equal(out.length, 3, "no head swap — all three render (marker as its user text)"); }); + it("an unresolved newest cutoff preserves the full-history fallback, including summary text", () => { + const kept = newUserMessage("retained exchange"); + const old = newCompactionMarker("OLD SUMMARY", { firstKeptMessageId: kept.id, tokensBefore: 1, at: "fixture" }); + const fresh = newCompactionMarker("LATEST SUMMARY", { firstKeptMessageId: "missing", tokensBefore: 2, at: "fixture" }); + const messages = [newUserMessage("earliest exchange"), kept, old, newUserMessage("latest reply"), fresh]; + const original = JSON.stringify(messages); + const out = toLlmMessages(messages); + assert.equal(out.length, messages.length); + assert.equal(out[0]!.content, "earliest exchange", "do not silently apply the older cutoff"); + const flat = JSON.stringify(out); + assert.ok(flat.includes("OLD SUMMARY")); + assert.ok(flat.includes("LATEST SUMMARY")); + assert.equal(flat.split(COMPACTED_HISTORY_HEADER).length - 1, 2); + assert.equal(JSON.stringify(messages), original); + }); + it("chained compaction: the newest marker's cut wins; the older marker falls in the dropped span", () => { const oldMarker = newCompactionMarker("OLD", { firstKeptMessageId: "m1", tokensBefore: 1, at: "t" }); const m1 = newUserMessage("x"); @@ -303,6 +335,11 @@ describe("serializeForSummary + buildCompactionPrompt", () => { assert.ok(!text.toLowerCase().includes("plan-block"), "UI-only markers never serialize"); }); + it("preserves legacy summary text when the caller has no separately supplied prior summary", () => { + const marker = newCompactionMarker("LEGACY SUMMARY", { firstKeptMessageId: "missing", tokensBefore: 1, at: "fixture" }); + assert.ok(serializeForSummary([marker]).includes("LEGACY SUMMARY")); + }); + it("the prompt demands the four sections in order and carries the plan tree verbatim", () => { const prompt = buildCompactionPrompt({ spanText: "TRANSCRIPT", planTreeText: "- [done] A (a1)\n- A → B", kind: "teach" }); const order = ["The learner's understanding", "The teaching arc", "The plan dependency tree", "The current moment"].map( @@ -388,6 +425,11 @@ describe("toLlmMessages carries the leading system message (request chrome)", () const record = (): LhMessage[] => [ { id: "old", role: "user", content: "dropped history", timestamp: 1 }, { id: "kept", role: "user", content: "kept tail", timestamp: 2 }, + newCompactionMarker("SUPERSEDED SUMMARY", { + firstKeptMessageId: "kept", + tokensBefore: 98, + at: new Date(0).toISOString(), + }), newCompactionMarker("SUMMARY", { firstKeptMessageId: "kept", tokensBefore: 99, @@ -407,6 +449,8 @@ describe("toLlmMessages carries the leading system message (request chrome)", () : ((m.content as { text?: string }[])[0]?.text ?? ""); assert.ok(out.slice(2).some((m) => m.role === "user" && textOf(m) === "kept tail")); assert.ok(!out.some((m) => m.role === "user" && textOf(m) === "dropped history")); + assert.ok(!JSON.stringify(out).includes("SUPERSEDED SUMMARY")); + assert.equal(JSON.stringify(out).split(COMPACTED_HISTORY_HEADER).length - 1, 1); }); it("without a cut the chrome renders EXACTLY ONCE (no duplicate)", () => { From 683eb93bcca28aa1bb35c531fde4a0c1671e3c5b Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 26 Oct 2026 19:43:09 +0200 Subject: [PATCH 32/87] Fix model labels and catalog-backed setup initialization --- components/model-catalog.ts | 54 +++++++ components/model-picker.tsx | 52 +------ components/settings/agent-section.tsx | 52 ++----- components/settings/keys-section.tsx | 10 +- components/setup-dialog.tsx | 205 ++++++++++++++------------ components/usage-format.ts | 24 +-- tests/byok-refusal.test.ts | 8 +- tests/model-catalog-setup.test.tsx | 176 ++++++++++++++++++++++ tests/render-stats-bar.test.tsx | 16 +- tests/settings-pure.test.ts | 2 +- 10 files changed, 389 insertions(+), 210 deletions(-) create mode 100644 components/model-catalog.ts create mode 100644 tests/model-catalog-setup.test.tsx diff --git a/components/model-catalog.ts b/components/model-catalog.ts new file mode 100644 index 0000000..6a97320 --- /dev/null +++ b/components/model-catalog.ts @@ -0,0 +1,54 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { ModelCatalogResponse } from "@/src/core"; +import { onSettingsSaved } from "@/components/settings-signal"; + +// Shared stale-while-revalidate catalog. Metadata refreshes retain the last +// good snapshot; connection changes clear selectable models immediately. +let catalogCache: ModelCatalogResponse | undefined; +let catalogError = false; +let catalogInflight: Promise | undefined; +let catalogGeneration = 0; +let stopCatalogSignals: (() => void) | undefined; +const catalogReaders = new Set<() => void>(); +const publishCatalog = () => { for (const reader of catalogReaders) reader(); }; + +function fetchCatalog(revalidate = false): void { + if (revalidate) { catalogGeneration++; catalogInflight = undefined; } + if (catalogInflight) return; + const generation = catalogGeneration; + catalogError = false; + publishCatalog(); + catalogInflight = fetch("/api/models") + .then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise; }) + .then((c) => { + if (generation === catalogGeneration) { catalogCache = c; catalogError = false; publishCatalog(); } + }) + .catch(() => { + if (generation === catalogGeneration) { catalogError = true; publishCatalog(); } + }) + .finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; }); +} + +export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; failed: boolean; refresh: () => void } { + const [snapshot, setSnapshot] = useState(() => ({ catalog: catalogCache, failed: catalogError })); + useEffect(() => { + const first = catalogReaders.size === 0; + const read = () => setSnapshot({ catalog: catalogCache, failed: catalogError }); + catalogReaders.add(read); + if (first) { + stopCatalogSignals = onSettingsSaved((section) => { + if (section === "keys") { + catalogGeneration++; catalogInflight = undefined; catalogCache = undefined; + fetchCatalog(); + } else if (section === "models") fetchCatalog(true); + }); + fetchCatalog(true); + } else fetchCatalog(); + read(); + return () => { catalogReaders.delete(read); if (!catalogReaders.size) { stopCatalogSignals?.(); stopCatalogSignals = undefined; } }; + }, []); + const refresh = useCallback(() => fetchCatalog(true), []); + return { ...snapshot, refresh }; +} diff --git a/components/model-picker.tsx b/components/model-picker.tsx index 1ae092d..b618e73 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -8,52 +8,11 @@ import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-men import { composeSlotModel } from "@/components/settings/agent-section"; import { MenuShell } from "@/components/menu-shell"; import { Hint } from "@/components/ui/hint"; -import { notifySettingsSaved, onSettingsSaved, useSettingsSaved } from "@/components/settings-signal"; +import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal"; +import { useModelCatalog } from "@/components/model-catalog"; +export { useModelCatalog } from "@/components/model-catalog"; import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default"; -/** GET /api/models is cached per page load for instant menu opens, but - * the federation is LIVE per call — keys saved mid-session widen the - * catalog immediately server-side. ModelControl therefore re-fetches on - * every model-menu open (stale-while-revalidate: the cached list shows - * instantly, the fresh one swaps in while the menu is open). */ -let catalogCache: ModelCatalogResponse | undefined; -let catalogInflight: Promise | undefined; -let catalogGeneration = 0; -let stopCatalogSignals: (() => void) | undefined; -const catalogReaders = new Set<() => void>(); -const publishCatalog = () => { for (const reader of catalogReaders) reader(); }; -function fetchCatalog(revalidate = false): void { - if (revalidate) { catalogGeneration++; catalogInflight = undefined; } - if (catalogInflight) return; - const generation = catalogGeneration; - catalogInflight = fetch("/api/models") - .then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise; }) - .then((c) => { if (generation === catalogGeneration) { catalogCache = c; publishCatalog(); } }) - .catch(() => { /* existing unavailable/usage-only posture, no retry timer */ }) - .finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; }); -} -export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh: () => void } { - const [catalog, setCatalog] = useState(catalogCache); - useEffect(() => { - const first = catalogReaders.size === 0; - const read = () => setCatalog(catalogCache); - catalogReaders.add(read); - if (first) { - stopCatalogSignals = onSettingsSaved((section) => { - if (section === "keys") { - catalogGeneration++; catalogInflight = undefined; catalogCache = undefined; - publishCatalog(); fetchCatalog(); - } else if (section === "models") fetchCatalog(true); - }); - fetchCatalog(true); - } else fetchCatalog(); - read(); - return () => { catalogReaders.delete(read); if (!catalogReaders.size) { stopCatalogSignals?.(); stopCatalogSignals = undefined; } }; - }, []); - const refresh = useCallback(() => fetchCatalog(true), []); - return { catalog, refresh }; -} - /** provider:id → contextWindow over the cached catalog — the client-side * twin of the server's catalogContextLimits join (context-meter * amendment, owner 2026-09-23: pi's live catalog is the ONLY limit @@ -315,10 +274,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod return (
diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index bd7c810..7c79b64 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -55,6 +55,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { MenuShell } from "@/components/menu-shell"; import { useSettingsSaved } from "@/components/settings-signal"; import { defaultOrderLevel, populateLevel } from "@/components/thinking-default"; +import { useModelCatalog } from "@/components/model-catalog"; +import { Hint } from "@/components/ui/hint"; import type React from "react"; import { COMPACTION_THRESHOLD_FLOOR, @@ -63,7 +65,6 @@ import { type AgentSettings, type CompactionSettings, type GraphSettings, - type ModelCatalogEntry, type ModelSlotName, type ModelsSettings, } from "@/src/core"; @@ -101,15 +102,6 @@ interface SegmentInfo { shared: boolean; } -/** The GET /api/models response fields this section consumes. */ -interface ModelsBody { - models: ModelCatalogEntry[]; - /** The three tiers' RESOLVED pins (BYOK: NULL per unset tier — no - * factory floor) — machinery only (the thinking list fallback); the - * rows' displays read the record override, never a phantom pin. */ - slots: Record; -} - /** Compose: one tool toggled, the rest of the section carried verbatim. * Pure — exported for tests. */ export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings { @@ -234,8 +226,10 @@ function MenuTrigger({ onClick, widthClass, disabled, + hint, }: { value: string; + hint?: string; /** What the trigger shows when there is no override ("Default"). */ emptyLabel: string; ariaLabel: string; @@ -250,7 +244,7 @@ function MenuTrigger({ /** The no-key search state grays the whole row; the trigger follows. */ disabled?: boolean; }) { - return ( + const button = ( ); + return hint ? {button} : button; } /* The section's dropdowns all mount the shared MenuShell (fix wave chunk @@ -303,10 +298,9 @@ export function AgentSection({ /** Writes the whole compaction section (replace-PUT discipline). */ onCompactionChange: (next: CompactionSettings) => void; }) { - const [catalog, setCatalog] = useState(null); - /** The three tiers' resolved pins — GET /api/models `slots`. The rows' - * Default hints and thinking lists resolve from it. */ - const [slots, setSlots] = useState(null); + const { catalog: body, failed: catalogFailed, refresh: refreshCatalog } = useModelCatalog(); + const catalog = body?.models; + const slots = body?.slots; /** The live search state (/api/search) — the row's reason line. */ const [search, setSearch] = useState<{ available: boolean; engine: string | null; reason: string } | null>(null); /** Which search engines have a key (/api/keys) — the no-keys-anywhere @@ -341,28 +335,9 @@ export function AgentSection({ .catch(() => {}); }, []); - // The catalog (federated: keyed providers only), the resolved slots, - // the prompt segments, and the search engines' key presence — one - // fetch each per section mount. The models read ALSO re-runs on the - // cross-surface signal (fix wave: another surface's write re-reads - // into the slot rows live). - const modelsRequest = useRef(0); - const modelsMounted = useRef(false); - const readModels = useCallback((connectionChanged = false) => { - const request = ++modelsRequest.current; - if (connectionChanged) setCatalog(null); - fetch("/api/models") - .then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); }) - .then((body: ModelsBody) => { - if (!modelsMounted.current || request !== modelsRequest.current) return; - setCatalog(body.models); - setSlots(body.slots); - }) - .catch(() => { if (modelsMounted.current && request === modelsRequest.current) setCatalog(null); }); - }, []); - + // The shared catalog seeds the first frame and refreshes per mount and + // keys/models signal. Settings props still own saved selections/levels. useSettingsSaved((section) => { - if (section === "models" || section === "keys") readModels(section === "keys"); // THE state-echo root cause: the prompt switches write through the // agent section, but this view (the toggles' truth) never re-read — // the settings-saved law (every reader re-reads) was missing this @@ -371,8 +346,6 @@ export function AgentSection({ }); useEffect(() => { - modelsMounted.current = true; - readModels(); fetch("/api/settings/prompts") .then((r) => r.json()) .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)) @@ -388,7 +361,6 @@ export function AgentSection({ setEngineKeys(present); }) .catch(() => {}); - return () => { modelsMounted.current = false; modelsRequest.current++; }; }, []); // The reason line re-reads /api/search AFTER an engine write lands — @@ -615,6 +587,7 @@ export function AgentSection({ return ( <> + {catalogFailed &&

Model catalog could not be loaded. Check the connection and retry.

} {MODEL_SLOTS.map((slot) => { const override = models?.[slot]; const modelId = override?.model?.id ?? ""; @@ -652,6 +625,7 @@ export function AgentSection({ {slot !== "strong" && <>} p.envVar === id); + if (matches?.some((p) => p.id === "opencode") && matches.some((p) => p.id === "opencode-go")) return "OpenCode (Zen / Go)"; + return matches?.[0]?.label; +} + /** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an * environment-owned value — the row itself is read-only then). Pure. */ export function keyStatusLine(status: AuthKeyStatus): string { @@ -417,7 +425,7 @@ export function KeysSection({ {providerRows.map((status) => ( p.envVar === status.id)?.label ?? status.info?.label ?? status.id} + label={providerKeyLabel(status.id, providers) ?? status.info?.label ?? status.id} docsUrl={status.info?.docsUrl} status={status} expanded={openRow === status.id} diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx index 461e507..641898f 100644 --- a/components/setup-dialog.tsx +++ b/components/setup-dialog.tsx @@ -13,7 +13,8 @@ * STAGED BY DEPENDENCY (owner, 2026-09-20: "first keys, then models — * there's no point presenting the model picker before the user has set a * key because there are no models to pick then"): - * Stage 1 — no keyed provider: a SELF-CONTAINED first-key screen + * Unknown catalog — neutral loading (or error/Retry), never a key screen. + * Stage 1 — no keyed provider or saved selection: a SELF-CONTAINED first-key screen * (zero-keys wave, owner 2026-09-25): provider picker + one key * input + save via PUT /api/keys/:id. The dialog STAYS MOUNTED — * saving dispatches the "keys" signal, the fetch effect re-runs on @@ -23,6 +24,8 @@ * Stage 2 — Strong → Cheap → Vision pickers over the FEDERATED catalog * (keyed providers only), the vision slot filtered to image-capable * models, in the app's shared dropdown language (ModelMenuList). + * Saved disconnected selections stay visible; Add provider opens the + * connection body when no provider remains. Refreshes preserve edits. * All three required (owner: no skip, no defaults); Save PUTs the * models section and reports onDone(models). * @@ -41,6 +44,7 @@ import { prettyModel } from "@/components/usage-format"; import { ModelMenuList } from "@/components/settings/model-menu"; import { MenuShell } from "@/components/menu-shell"; import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal"; +import { useModelCatalog } from "@/components/model-catalog"; import { Hint } from "@/components/ui/hint"; import { type ProviderKeyInfo } from "@/components/settings/keys-section"; import { ProviderMenuList } from "@/components/settings/model-menu"; @@ -73,27 +77,26 @@ interface ModelsBody { * (owner's dependency ordering). Exported for tests. */ export function setupStage( providers: { id: string; keyPresent: boolean }[] | null, -): "keys" | "pickers" { - if (providers === null) return "keys"; // fetch failed → the honest fallback +): "loading" | "keys" | "pickers" { + if (providers === null) return "loading"; return providers.some((p) => p.keyPresent) ? "pickers" : "keys"; } -/** Prefill (owner, BYOK wave 2026-09-22): each pinned tier opens with its - * pick — matched in the FEDERATED catalog (a lapsed key's pin does not - * prefill); unpinned tiers stay empty and carry the "needed" mark. - * First-run = nothing pinned → all three empty + marked. Exported pure - * for tests. */ +type ModelSelection = Pick; + +/** Saved identity is independent of selectable metadata. A disconnected + * pin still prefills; only the menu is restricted to the live catalog. */ export function prefillFromSlots( models: ModelCatalogEntry[], slots: Record | undefined, -): Partial> { - const pre: Partial> = {}; +): Partial> { + const pre: Partial> = {}; for (const slot of SLOT_ORDER) { const pin = slots?.[slot]; const entry = pin?.model ? models.find((m) => m.provider === pin.model!.provider && m.id === pin.model!.id) : undefined; - if (entry) pre[slot] = entry; + if (pin?.model) pre[slot] = entry ?? pin.model; } return pre; } @@ -217,24 +220,39 @@ export function SetupKeysBody({ ); } -export function SetupDialog({ - open, - onClose, - /** Saved: the models section landed; the caller marks configured (the - * draft stays put — save never sends). */ - onDone, -}: { +function prefillLevels(body: ModelsBody | undefined): Partial> { + const levels: Partial> = {}; + for (const slot of SLOT_ORDER) { + const pin = body?.slots?.[slot]; + const entry = body?.models.find((m) => m.provider === pin?.model.provider && m.id === pin.model.id); + if (pin?.thinkingLevel !== undefined) levels[slot] = pin.thinkingLevel; + else if (entry) levels[slot] = prefillLevelFromPin(entry, pin); + } + return levels; +} + +interface SetupDialogProps { open: boolean; onClose: () => void; + /** Saving never sends the caller's draft. */ onDone: (models: ModelsSettings) => void; -}) { +} + +export function SetupDialog(props: SetupDialogProps) { + // An open owns its draft. Mount-time cache seeding avoids a key-screen + // first frame, and reopening cannot reuse a previous open's edits. + return props.open ? : null; +} + +function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { const backdropDismiss = useBackdropDismiss(onClose); - const [body, setBody] = useState(null); - const [failed, setFailed] = useState(false); - const [picked, setPicked] = useState>>({}); - /** Per-slot thinking level (chunk 2): populated from the ordering on - * every fresh model pick, editable — an explicit pick always wins. */ - const [levels, setLevels] = useState>>({}); + const { catalog, failed, refresh } = useModelCatalog(); + const [lastBody, setLastBody] = useState(catalog); + const body = catalog ?? lastBody; + const [picked, setPicked] = useState(() => prefillFromSlots(catalog?.models ?? [], catalog?.slots)); + const [levels, setLevels] = useState(() => prefillLevels(catalog)); + const editedSlots = useRef(new Set()); + const [showKeys, setShowKeys] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [openMenu, setOpenMenu] = useState<{ slot: ModelSlotName; kind: "model" | "thinking" } | null>(null); @@ -249,9 +267,8 @@ export function SetupDialog({ const [keyError, setKeyError] = useState(null); const [keyMenuOpen, setKeyMenuOpen] = useState(false); // Stage 1's provider-picker trigger wrapper — the shared shell's anchor. - // Declared with the other hooks: the early `if (!open) return null` sits - // below them all. (Stage 2's slot menus anchor by anchorTestid instead — - // see the slot MenuShell below for why a ref cannot work there.) + // Stage 2's slot menus anchor by anchorTestid instead — see the slot + // MenuShell below for why a freshly attached ref cannot work there. const keyTriggerRef = useRef(null); // The keys signal (zero-keys wave): keys-section saves/deletes AND @@ -263,50 +280,44 @@ export function SetupDialog({ if (section === "keys") setKeysTick((t) => t + 1); }); - // Fresh /api/models per open AND per keys change — a key saved a - // moment ago (stage 1's own screen) widens the pickers immediately. useEffect(() => { - if (!open) return; let cancelled = false; - setBody(null); - setPicked({}); - setLevels({}); - setError(null); fetch("/api/keys") .then((r) => r.json()) .then((b: { providers?: ProviderKeyInfo[] }) => { if (!cancelled) setKeyProviders(b.providers ?? []); }) .catch(() => { if (!cancelled) setKeyProviders([]); }); - fetch("/api/models") - .then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); }) - .then((b: ModelsBody) => { - if (cancelled) return; - setBody(b); - setFailed(false); - // Prefill (owner, 2026-09-22): each pinned tier opens with its - // pick (matched in the FEDERATED catalog — a lapsed key's pin - // does not prefill); unpinned tiers stay empty and carry the - // "needed" mark. First-run = nothing pinned → all three marked. - const pre = prefillFromSlots(b.models, b.slots); - setPicked(pre); - // Levels prefill with the pins (chunk 2): stored level if still - // supported, else the ordering's level for the pinned model. - const preLevels: Partial> = {}; - for (const slot of SLOT_ORDER) { - const entry = pre[slot]; - if (entry) preLevels[slot] = prefillLevelFromPin(entry, b.slots?.[slot]); - } - setLevels(preLevels); - }) - .catch(() => { if (!cancelled) setFailed(true); }); return () => { cancelled = true; }; - }, [open, keysTick]); + }, [keysTick]); + + useEffect(() => { + if (!catalog) return; + setLastBody(catalog); + const pre = prefillFromSlots(catalog.models, catalog.slots); + const preLevels = prefillLevels(catalog); + // Fresh metadata is always used, but a response cannot reset an + // edited slot, including a level chosen while the request was pending. + setPicked((prev) => { + const next = { ...prev }; + for (const slot of SLOT_ORDER) if (!editedSlots.current.has(slot)) { + if (pre[slot]) next[slot] = pre[slot]; else delete next[slot]; + } + return next; + }); + setLevels((prev) => { + const next = { ...prev }; + for (const slot of SLOT_ORDER) if (!editedSlots.current.has(slot)) { + if (preLevels[slot] !== undefined) next[slot] = preLevels[slot]; else delete next[slot]; + } + return next; + }); + if (catalog.providers.some((p) => p.keyPresent)) setShowKeys(false); + }, [catalog]); // Esc + outside-click — layered dismissal, closable WITHOUT // configuring (browsing stays possible; the gate is the send // interception, not this dialog). An open slot menu eats Esc first // (and any click outside it); only a bare Esc closes the dialog. useEffect(() => { - if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; // THE ONE-ESC LAW (owner, keyboard round 2026-10-06): claim at @@ -341,11 +352,10 @@ export function SetupDialog({ document.removeEventListener("keydown", onKey); document.removeEventListener("pointerdown", onPointerDown); }; - }, [open, onClose, openMenu, keyMenuOpen]); + }, [onClose, openMenu, keyMenuOpen]); - if (!open) return null; - - const stage = failed ? "keys" : setupStage(body ? body.providers : null); + const hasSelections = Object.keys(picked).length > 0 || SLOT_ORDER.some((slot) => body?.slots?.[slot]?.model); + const stage = hasSelections ? "pickers" : setupStage(body ? body.providers : null); const allPicked = SLOT_ORDER.every((s) => picked[s]); /** Stage 1's save: PUT the pasted key, dispatch the keys signal, and @@ -398,6 +408,20 @@ export function SetupDialog({ onDone(models); } + const keysBody = setKeyMenuOpen((v) => !v)} + onPick={(p) => { setKeyProvider(p); setKeyMenuOpen(false); setKeyError(null); }} + onValue={setKeyValue} + onSave={() => void saveKey()} + />; + return createPortal(
- {stage === "keys" ? ( -
- {failed ? ( -

- Model catalog could not be loaded. Check the connection and retry. -

- ) : ( - setKeyMenuOpen((v) => !v)} - onPick={(p) => { - setKeyProvider(p); - setKeyMenuOpen(false); - setKeyError(null); - }} - onValue={setKeyValue} - onSave={() => void saveKey()} - /> - )} -
+ {failed && ( +

+ Model catalog could not be loaded. Check the connection and retry. +

+ )} + {stage === "loading" ? ( + !failed &&

Loading models…

+ ) : stage === "keys" ? ( +
{keysBody}
) : (

Pick the model for each tier. All three are required before chats can start.

+ {!body?.providers.some((p) => p.keyPresent) && <> + + {showKeys && keysBody} + } {SLOT_ORDER.map((slot) => { const entry = picked[slot]; const level = levels[slot]; - const levelsForEntry = entry?.supportedLevels ?? []; - const catalog = - slot === "vision" ? body?.models.filter((m) => m.input.includes("image")) : body?.models; + const metadata = catalog?.models.find((m) => m.id === entry?.id && m.provider === entry.provider); + // Unavailable metadata cannot erase or clamp a saved preference. + const levelsForEntry = metadata?.supportedLevels ?? (entry ? Object.keys(THINKING_LABEL) : []); + const available = catalog?.models ?? (failed ? [] : undefined); + const slotCatalog = slot === "vision" ? available?.filter((m) => m.input.includes("image")) : available; return (
@@ -481,6 +494,7 @@ export function SetupDialog({ {slot !== "strong" && <>}
+ + {/* Anchor by TESTID, not a ref (the settings-section pattern): React attaches a host element's ref AFTER its descendants' layout effects run, so a ref freshly attached in this same @@ -512,10 +527,11 @@ export function SetupDialog({ menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl" > { + editedSlots.current.add(slot); setPicked((prev) => ({ ...prev, [slot]: m })); // Auto-populate (chunk 2, owner: "populate // everywhere"): no explicit level → the @@ -527,7 +543,7 @@ export function SetupDialog({ })); setOpenMenu(null); }} - footer={catalog && catalog.length === 0 ? "No image-capable models. Add a provider key with access to a vision model." : undefined} + footer={slotCatalog && slotCatalog.length === 0 && slot === "vision" ? "No image-capable models. Add a provider key with access to a vision model." : undefined} /> )} @@ -563,6 +579,7 @@ export function SetupDialog({ levels={levelsForEntry.map(String)} effectiveLevel={level} onPick={(lv) => { + editedSlots.current.add(slot); setLevels((prev) => ({ ...prev, [slot]: lv as ThinkingLevel })); setOpenMenu(null); }} diff --git a/components/usage-format.ts b/components/usage-format.ts index 3a400cd..2bd7d60 100644 --- a/components/usage-format.ts +++ b/components/usage-format.ts @@ -29,25 +29,11 @@ export function splitQualified(id: string): { provider: string | null; id: strin return { provider: id.slice(0, colon), id: id.slice(colon + 1) }; } -/** - * "z-ai/glm-5.3-flash" → "glm-5.3-flash" — vendor prefix dropped, the - * name KEPT EXACTLY as its provider writes it (dashes stay, no - * auto-caps — owner rounds 4-5, 2026-10-05); a trailing all-digit - * segment (OpenRouter's date suffix, "qwen3.8-max-0902") is dropped. - * Quiet-chip friendly. - * - * With `qualified` (usage stamps are "provider:id", model-slots wave): - * the provider rides as a quiet suffix — "glm-5.3-flash · openrouter" — - * because with federation the same model via two providers is genuinely - * different bookkeeping. Bare catalog ids are unchanged. - */ +/** Model-only labels omit the vendor path, never versions, dates or variants. + * Only usage callers pass `qualified`: a bare catalog id's colon belongs to + * the model (e.g. :free), not a provider. Usage retains its provider suffix. */ export function prettyModel(id: string, qualified = false): string { - const { provider, id: bare } = splitQualified(id); + const { provider, id: bare } = qualified ? splitQualified(id) : { provider: null, id }; const short = bare.includes("/") ? bare.slice(bare.lastIndexOf("/") + 1) : bare; - const parts = short.split("-"); - const trimmed = /^\d+$/.test(parts[parts.length - 1] ?? "") ? parts.slice(0, -1) : parts; - // No auto-capitalization, dashes KEPT (owner rounds 4-5, 2026-10-05): - // model names render exactly as their providers write them. - const pretty = trimmed.join("-"); - return qualified && provider ? `${pretty} · ${provider}` : pretty; + return provider ? `${short} · ${provider}` : short; } \ No newline at end of file diff --git a/tests/byok-refusal.test.ts b/tests/byok-refusal.test.ts index fab7db2..7627b8f 100644 --- a/tests/byok-refusal.test.ts +++ b/tests/byok-refusal.test.ts @@ -123,9 +123,9 @@ describe("prefillFromSlots — the SetupDialog prefill (BYOK wave)", () => { assert.deepEqual(pre, {}); }); - it("a pin whose model left the federated catalog does not prefill (lapsed key)", () => { + it("a disconnected saved identity still prefills without selectable metadata", () => { const pre = prefillFromSlots(catalog, { strong: { model: { provider: "gone", id: "vanished" } }, cheap: null, vision: null }); - assert.deepEqual(pre, {}); + assert.deepEqual(pre, { strong: { provider: "gone", id: "vanished" } }); }); it("provider collisions never cross-pollute (same id, different provider)", () => { @@ -158,9 +158,9 @@ describe("prefillLevelFromPin — the setup dialog's level prefill (chunk 2)", ( }); }); -describe("setupStage — unchanged by the BYOK wave", () => { +describe("setupStage — unknown is not keyless", () => { it("keys first (owner ordering), pickers when a provider is keyed", () => { - assert.equal(setupStage(null), "keys"); + assert.equal(setupStage(null), "loading"); assert.equal(setupStage([{ id: "x", keyPresent: false }]), "keys"); assert.equal(setupStage([{ id: "x", keyPresent: true }]), "pickers"); }); diff --git a/tests/model-catalog-setup.test.tsx b/tests/model-catalog-setup.test.tsx new file mode 100644 index 0000000..43db670 --- /dev/null +++ b/tests/model-catalog-setup.test.tsx @@ -0,0 +1,176 @@ +import React, { act } from "react"; +(globalThis as Record).React = React; +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { createRoot, type Root } from "react-dom/client"; +import { parseHTML } from "linkedom"; +import { SetupDialog } from "../components/setup-dialog.tsx"; +import { AgentSection } from "../components/settings/agent-section.tsx"; +import { providerKeyLabel, classifyKeys } from "../components/settings/keys-section.tsx"; +import { useModelCatalog } from "../components/model-catalog.ts"; +import { notifySettingsSaved } from "../components/settings-signal.ts"; +import type { ModelCatalogEntry, ModelCatalogResponse, ModelsSettings } from "../src/core/index.ts"; + +const dom = parseHTML(""); +Object.assign(globalThis, { + window: dom.window, document: dom.document, Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, HTMLInputElement: dom.window.HTMLInputElement, + CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, + requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(performance.now()), 0), + cancelAnimationFrame: (id: number) => clearTimeout(id), +}); +Object.assign(dom.window, { innerWidth: 1280, innerHeight: 800 }); +dom.window.HTMLElement.prototype.scrollIntoView = () => {}; +const originalFetch = globalThis.fetch; +let root: Root | undefined; +let host: HTMLDivElement | undefined; +let requests: ReturnType>[] = []; +let writes: ModelsSettings[] = []; +let snapshot: ReturnType; +function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; }); return { resolve, promise }; } +const provider = { id: "fixture", envVar: "FIXTURE_KEY", label: "Fixture" }; +const a: ModelCatalogEntry = { id: "vendor/model:free", name: "Model free", provider: provider.id, input: ["text", "image"], reasoning: true, supportedLevels: ["off", "low", "high"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024 }; +const b: ModelCatalogEntry = { ...a, id: "claude-opus-5-5", name: "Claude Opus 5.5", supportedLevels: ["off", "medium"] }; +function catalog(connected = true): ModelCatalogResponse { + return { + models: connected ? [a, b] : [], defaults: null, + rolePins: { researcher: null, demoBuilder: null, visualMaker: null, extractor: null, auditor: null }, + slots: { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" }, cheap: { model: { provider: a.provider, id: a.id }, thinkingLevel: "high" }, vision: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" } }, + providers: [{ ...provider, keyPresent: connected }], + }; +} +function Reader() { snapshot = useModelCatalog(); return null; } +function setup() { return {}} onDone={() => {}} />; } +function installFetch() { + requests = []; writes = []; + globalThis.fetch = async (input, init) => { + const path = String(input); + if (path === "/api/models") { const request = deferred(); requests.push(request); return request.promise; } + if (path === "/api/settings/models") { writes.push(JSON.parse(String(init?.body))); return Response.json({ settings: { models: writes.at(-1) } }); } + if (path === "/api/keys") return Response.json({ providers: [provider], keys: [] }); + if (path === "/api/settings/prompts") return Response.json({ prober: [], teacher: [] }); + if (path === "/api/search") return Response.json({ available: false, engine: null, reason: "fixture" }); + throw new Error(`Unexpected request ${path}`); + }; +} +async function render(children: React.ReactNode) { + if (!root) { host = document.createElement("div"); document.body.append(host); root = createRoot(host); } + await act(async () => root!.render(children)); +} +async function answer(index: number, body: ModelCatalogResponse | null) { await act(async () => requests[index]!.resolve(body ? Response.json(body) : new Response("unavailable", { status: 503 }))); } +function element(testid: string) { const e = document.querySelector(`[data-testid="${testid}"]`); assert.ok(e, testid); return e; } +async function click(testid: string) { await act(async () => element(testid).click()); } +function levels() { return [...document.querySelectorAll('[data-testid^="thinking-option-"]')].map((e) => e.textContent); } +async function warm(body = catalog()) { await render(); await answer(0, body); } + +afterEach(async () => { + await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; + globalThis.fetch = originalFetch; +}); + +test("cold setup is loading, not keyless; failure/Retry does not invent a key stage", async () => { + installFetch(); await render(setup()); + assert.ok(element("setup-stage-loading")); + assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null); + await answer(0, null); + assert.ok(element("setup-catalog-error")); + assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null); + await click("setup-retry"); assert.ok(element("setup-stage-loading")); + const empty = catalog(false); empty.slots = { strong: null, cheap: null, vision: null }; + await answer(1, empty); assert.ok(element("setup-stage-keys")); +}); + +test("cached setup is immediately usable; fresh metadata/untouched slots update without wiping model or thinking edits; failed refresh still allows exact-ID Save", async () => { + installFetch(); await warm(); + await render(<>{setup()}); + assert.equal(requests.length, 2); + assert.ok(element("setup-stage-pickers")); + assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null); + assert.equal(element("setup-trigger-strong").textContent, "model:free▾"); + await click("setup-trigger-strong"); await click("model-option-claude-opus-5-5"); + await click("setup-thinking-strong"); await click("thinking-option-medium"); + const fresh = catalog(); fresh.slots.cheap = { model: { provider: b.provider, id: b.id }, thinkingLevel: "off" }; + fresh.models[1] = { ...b, supportedLevels: ["off", "medium", "high"] }; + await answer(1, fresh); + assert.equal(element("setup-trigger-strong").textContent, "claude-opus-5-5▾"); + assert.equal(element("setup-thinking-strong").textContent?.trim(), "medium▾"); + assert.equal(element("setup-trigger-cheap").textContent, "claude-opus-5-5▾"); + await click("setup-thinking-strong"); assert.deepEqual(levels(), ["off", "medium✓", "high"]); + await click("setup-thinking-strong"); + await act(async () => snapshot.refresh()); await answer(2, null); + assert.ok(element("setup-catalog-error")); assert.equal(element("setup-save").hasAttribute("disabled"), false); + await click("setup-save"); + assert.deepEqual(writes[0], { + strong: { model: { provider: b.provider, id: b.id }, thinkingLevel: "medium" }, + cheap: { model: { provider: b.provider, id: b.id }, thinkingLevel: "off" }, + vision: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" }, + }); +}); + +test("disconnect invalidates choices, not saved selections or thinking; all-disconnected setup offers Add provider", async () => { + installFetch(); await warm(); await render(<>{setup()}); await answer(1, catalog()); + await click("setup-trigger-strong"); + assert.ok(document.querySelector('[data-testid="model-option-claude-opus-5-5"]')); + await act(async () => notifySettingsSaved("keys")); + assert.equal(document.querySelector('[data-testid="model-option-claude-opus-5-5"]'), null, "stale choices disappear before the fresh request resolves"); + await answer(2, catalog(false)); + assert.equal(element("setup-trigger-strong").textContent, "model:free▾"); + assert.equal(element("setup-thinking-strong").textContent?.trim(), "low▾"); + assert.ok(element("setup-add-provider")); await click("setup-add-provider"); assert.ok(element("setup-stage-keys")); + await click("setup-thinking-strong"); await click("thinking-option-xhigh"); + assert.equal(element("setup-thinking-strong").textContent?.trim(), "xhigh▾"); + await click("setup-save"); assert.equal(writes[0]?.strong?.thinkingLevel, "xhigh"); assert.equal(writes[0]?.strong?.model?.id, a.id); +}); + +test("cold reconnect checks saved pins before choosing the key stage, and reopening gets a new draft", async () => { + installFetch(); await warm(); + await act(async () => notifySettingsSaved("keys")); + const stages: Array = []; + function Probe() { + const { catalog } = useModelCatalog(); + React.useLayoutEffect(() => { stages.push(document.querySelector('[data-testid^="setup-stage-"]')?.dataset.testid); }, [catalog]); + return null; + } + await render(<>{setup()}); + assert.ok(element("setup-stage-loading")); + await answer(1, catalog(false)); + assert.deepEqual(stages, ["setup-stage-loading", "setup-stage-pickers"]); + await click("setup-thinking-strong"); await click("thinking-option-xhigh"); + await render(); + await render(<>{setup()}); + assert.equal(element("setup-thinking-strong").textContent?.trim(), "low▾", "a reopened draft uses saved state, not the abandoned edit"); + await answer(2, catalog(false)); +}); + +test("Agent opens with cached thinking metadata; refresh and disconnect never clear saved levels", async () => { + installFetch(); await warm(); + const models: ModelsSettings = { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "low" } }; + let changed: ModelsSettings | undefined; + await render(<> {}} onModelsChange={(next) => { changed = next; }} onCompactionChange={() => {}} />); + await click("slot-thinking-strong"); assert.deepEqual(levels(), ["off", "low✓", "high"]); + assert.equal(element("slot-model-strong").textContent, "model:free▾"); + await answer(1, catalog()); + await act(async () => notifySettingsSaved("keys")); await answer(2, catalog(false)); + assert.equal(element("slot-thinking-strong").textContent, "low▾"); + await click("thinking-option-xhigh"); assert.equal(changed?.strong?.thinkingLevel, "xhigh"); assert.equal(changed?.strong?.model?.id, a.id); +}); + +test("shared refresh fences late failure as well as late success, and Retry clears a current error", async () => { + installFetch(); await warm(); + await act(async () => snapshot.refresh()); await act(async () => snapshot.refresh()); + const fresh = catalog(); fresh.models = [b]; + await answer(2, fresh); await answer(1, null); + assert.equal(snapshot.failed, false); assert.equal(snapshot.catalog?.models[0]?.id, b.id); + await act(async () => snapshot.refresh()); await answer(3, null); + assert.equal(snapshot.failed, true); assert.equal(snapshot.catalog?.models[0]?.id, b.id); + await act(async () => snapshot.refresh()); assert.equal(snapshot.failed, false); + await answer(4, fresh); +}); + +test("OpenCode's saved shared credential names both Pi providers without changing addable-provider semantics", () => { + const providers = [{ id: "opencode", envVar: "OPENCODE_API_KEY", label: "OpenCode Zen" }, { id: "opencode-go", envVar: "OPENCODE_API_KEY", label: "OpenCode Go" }]; + assert.equal(providerKeyLabel("OPENCODE_API_KEY", providers), "OpenCode (Zen / Go)"); + assert.equal(providerKeyLabel("OPENCODE_API_KEY", [...providers].reverse()), "OpenCode (Zen / Go)"); + assert.equal(providerKeyLabel("FIXTURE_KEY", [provider]), "Fixture"); + assert.equal(classifyKeys([{ id: "OPENCODE_API_KEY", info: null, present: true, masked: "…fake", source: "file", shadowedByEnv: false }], providers).addable.length, 0); +}); diff --git a/tests/render-stats-bar.test.tsx b/tests/render-stats-bar.test.tsx index 329f186..4f80fa0 100644 --- a/tests/render-stats-bar.test.tsx +++ b/tests/render-stats-bar.test.tsx @@ -89,9 +89,15 @@ describe("stats bar", () => { assert.match(empty, /data-testid="stats-context">\? { + it("prettyModel strips only the vendor path, preserving versions, dates and variants", () => { assert.equal(prettyModel("z-ai/glm-5.3-flash"), "glm-5.3-flash"); // dashes kept (owner round 5) - assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max"); + assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max-0902"); + assert.equal(prettyModel("google/gemma-4-27b-it:free"), "gemma-4-27b-it:free"); + assert.equal(prettyModel("anthropic/claude-opus-5:batch"), "claude-opus-5:batch"); + assert.equal(prettyModel("claude-opus-5"), "claude-opus-5"); + assert.equal(prettyModel("claude-opus-5-5"), "claude-opus-5-5"); + assert.equal(prettyModel("model:free"), "model:free"); + assert.equal(prettyModel("model:variant:free"), "model:variant:free"); }); it("provider-qualified stamps (usage contract, model-slots wave)", () => { @@ -99,8 +105,10 @@ describe("stats bar", () => { // the same model via two providers different bookkeeping assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash", true), "glm-5.3-flash · openrouter"); assert.equal(prettyModel("openai:gpt-5.2", true), "gpt-5.2 · openai"); - // unqualified on a qualified stamp: provider silently dropped (old shape) - assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash"), "glm-5.3-flash"); + assert.equal(prettyModel("openrouter:google/gemma-4-27b-it:free", true), "gemma-4-27b-it:free · openrouter"); + assert.equal(prettyModel("opencode:claude-opus-5-5", true), "claude-opus-5-5 · opencode"); + // Bare ids are never parsed as usage stamps, even with a colon. + assert.equal(prettyModel("model:free"), "model:free"); // qualified on a bare id: no suffix invented assert.equal(prettyModel("z-ai/glm-5.3-flash", true), "glm-5.3-flash"); }); diff --git a/tests/settings-pure.test.ts b/tests/settings-pure.test.ts index d629349..3c38f1d 100644 --- a/tests/settings-pure.test.ts +++ b/tests/settings-pure.test.ts @@ -320,7 +320,7 @@ describe("first-run gate — slotsConfigured + setupStage (model-slots wave)", ( }); it("setupStage: keys before models (owner's dependency ordering)", () => { - assert.equal(setupStage(null), "keys", "fetch failed → the honest fallback"); + assert.equal(setupStage(null), "loading", "unknown catalog must not claim keys are missing"); assert.equal(setupStage([]), "keys", "no providers = keys"); assert.equal(setupStage([{ id: "zai", keyPresent: false }]), "keys", "provider present but keyless"); assert.equal(setupStage([{ id: "zai", keyPresent: false }, { id: "openai", keyPresent: true }]), "pickers", "one keyed provider widens the pickers"); From 42814ba6a641c54b1e8ff4ce305734e3cf380f50 Mon Sep 17 00:00:00 2001 From: anikam13 Date: Mon, 26 Oct 2026 23:03:09 -0700 Subject: [PATCH 33/87] fix: budget dock height for answer controls and toolbar --- components/chat.tsx | 16 ++++++++++++++-- components/practice-dock.tsx | 2 +- components/question-dock.tsx | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index f8866f1..10ef099 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -2026,11 +2026,23 @@ function DockOverlay({ if (!el) return; const measure = () => { setSbInset(Math.round((el.offsetWidth - el.clientWidth) / 2)); - setDockMaxH(Math.max(el.clientHeight - 32, 160)); + const block = blockRef.current; + const question = block?.querySelector("[data-dock-scroll-area]"); + // The height budget belongs to the whole dock, including answer + // controls, stats, and padding. Only the question may consume the + // remaining space; otherwise the overlay grows behind the header. + const chrome = block && question ? block.offsetHeight - question.offsetHeight : 0; + setDockMaxH(Math.max(el.clientHeight - 32 - chrome, 0)); }; measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + if (blockRef.current) observer.observe(blockRef.current); window.addEventListener("resize", measure); - return () => window.removeEventListener("resize", measure); + return () => { + observer.disconnect(); + window.removeEventListener("resize", measure); + }; }, [scrollRef]); // GROWTH MUTE (owner round, 2026-10-05 — the generation churn): during a diff --git a/components/practice-dock.tsx b/components/practice-dock.tsx index 4c53b59..3ade8f4 100644 --- a/components/practice-dock.tsx +++ b/components/practice-dock.tsx @@ -105,7 +105,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi cards' label row — owner round, 2026-10-05) + the statement, markdown + LaTeX rendered, capped by the measured column (--dock-max-h; the old fixed 40vh cut long problems short). */} -
+
Problem
{input.statement} diff --git a/components/question-dock.tsx b/components/question-dock.tsx index 08f5d23..56b3298 100644 --- a/components/question-dock.tsx +++ b/components/question-dock.tsx @@ -564,7 +564,7 @@ function QuestionDockForm({ {/* Question area — scrolls internally if very tall; the bottom row stays put. Capped by the measured column (--dock-max-h; the old fixed 55dvh cut long questions short — owner round, 2026-10-05). */} -
+
{toolName === "probe" ? "Probe" From cb7e719e925c09defcd9a7bace1bc0637766a5b0 Mon Sep 17 00:00:00 2001 From: Casimir Arlabosse <11591428+casiar@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:46:28 +0800 Subject: [PATCH 34/87] test: resolve golden fixture path with import.meta.dirname new URL(import.meta.url).pathname keeps percent-encoding, so the test failed with ENOENT when the checkout path contains a space. Use import.meta.dirname like the other tests. Co-Authored-By: Claude Opus 5.5 --- tests/schema-stamps.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/schema-stamps.test.ts b/tests/schema-stamps.test.ts index bfb1cd3..acb4cda 100644 --- a/tests/schema-stamps.test.ts +++ b/tests/schema-stamps.test.ts @@ -110,7 +110,7 @@ test("render-settings: its write stamps the family sidecar too", () => { // ── the fixtures stay the legacy pin: golden data reads unstamped ─────────── test("golden fixtures are deliberately the unstamped legacy shape (lazy v1 = current-valid)", () => { - const here = path.dirname(new URL(import.meta.url).pathname); + const here = import.meta.dirname; const teach = JSON.parse(fs.readFileSync(path.join(here, "fixtures", "golden-data", "chats", "chat_mv27rrra_feff23b6.json"), "utf8")); assert.equal(teach.version, undefined, "fixture records pin the unstamped read path"); assert.ok(!fs.existsSync(path.join(here, "fixtures", "golden-data", "graph", "format.json")), "no sidecar in the fixture graph dir"); From d30acd4fddc40514b7c2d463f694ae3f745814f1 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 15:27:08 +0200 Subject: [PATCH 35/87] fix: finish measured dock sizing and short-window scrolling --- components/chat.tsx | 125 +++++++----------- components/dock-height.ts | 157 ++++++++++++++++++++++ tests/dock-height.test.tsx | 259 +++++++++++++++++++++++++++++++++++++ 3 files changed, 462 insertions(+), 79 deletions(-) create mode 100644 components/dock-height.ts create mode 100644 tests/dock-height.test.tsx diff --git a/components/chat.tsx b/components/chat.tsx index 10ef099..cef946d 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -26,6 +26,7 @@ import { Composer } from "@/components/composer"; import { useEnterSubmits } from "@/components/enter-submits"; import { blurAfterPointerClick } from "@/components/focus-hygiene"; import { dockStateKey } from "@/components/dock-state-key"; +import { forwardDockWheel, useDockHeight } from "@/components/dock-height"; import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers"; import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation"; import { useFieldCycle } from "@/components/field-cycle"; @@ -1972,6 +1973,17 @@ function DockOverlay({ const revealedRef = useRef(revealed); revealedRef.current = revealed; + const blockRef = useRef(null); + const viewportRef = useRef(null); + const bodyRef = useRef(null); + const shellRef = useRef(null); + const contentRef = useRef(null); + const mode = pending + ? `question:${pending.toolCallId}` + : practice + ? `practice:${practice.toolCallId}` + : "chat"; + // The plan marginalia's flank width (owner, plan-placement round): from // the DOCK container's right edge to the block's right edge — the bare // diagram's canvas. Tracked LIVE by a ResizeObserver on the block @@ -2010,40 +2022,33 @@ function DockOverlay({ return () => ro.disconnect(); }, []); + // The reserve grows immediately, but never shrinks while a question or + // practice problem owns the dock: shrinking flow space could pin a nearby + // learner back to the bottom. Composer mode may give space back. + const reserveRef = useRef(0); + const offer = useCallback((blockH: number) => { + const next = blockH + GRADIENT_H; + if (next <= reserveRef.current + 0.5 && pendingRef.current) return; + // Stamp growth at the source, including child-only field resizes, so + // stick-to-bottom's catch-up is not mistaken for the learner leaving. + if (shouldStampGeometryMute(next, reserveRef.current, pendingRef.current)) geometryAtRef.current = Date.now(); + reserveRef.current = next; + onReserve(next); + }, [onReserve]); + // The overlay must not paint over the scroller's scrollbar (owner, scroll // round 2): the scroller reserves symmetric gutters (scrollbar-gutter: // stable both-edges), so inset the overlay's right edge by the gutter — // zero on macOS overlay scrollbars, the real width on classic platforms. - // The same measure owns the QUESTION/PROBLEM HEIGHT CAP (owner round, - // 2026-10-05): the old fixed fractions (55dvh / 40vh) cut long problems - // off absurdly short — the cap is now the AVAILABLE COLUMN (the - // scroller already starts below the top bar) minus a 2rem breathing - // gap, carried as --dock-max-h for the docks' scroll areas. + // Budget the whole solid dock, leaving only its question area flexible. + // The hook measures natural, visible content before the morph/reserve + // effect below. Short windows scroll the reading/question/answer body; + // the stats/model toolbar remains outside that overflow viewport. const [sbInset, setSbInset] = useState(0); - const [dockMaxH, setDockMaxH] = useState(null); - useEffect(() => { - const el = scrollRef.current; - if (!el) return; - const measure = () => { - setSbInset(Math.round((el.offsetWidth - el.clientWidth) / 2)); - const block = blockRef.current; - const question = block?.querySelector("[data-dock-scroll-area]"); - // The height budget belongs to the whole dock, including answer - // controls, stats, and padding. Only the question may consume the - // remaining space; otherwise the overlay grows behind the header. - const chrome = block && question ? block.offsetHeight - question.offsetHeight : 0; - setDockMaxH(Math.max(el.clientHeight - 32 - chrome, 0)); - }; - measure(); - const observer = new ResizeObserver(measure); - observer.observe(el); - if (blockRef.current) observer.observe(blockRef.current); - window.addEventListener("resize", measure); - return () => { - observer.disconnect(); - window.removeEventListener("resize", measure); - }; - }, [scrollRef]); + useDockHeight({ + scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef, + expanded, enabled: !!pending || !!practice, mode, onScrollbarInset: setSbInset, onHeight: offer, + }); // GROWTH MUTE (owner round, 2026-10-05 — the generation churn): during a // live turn the transcript content grows and stick-to-bottom chases it; @@ -2079,48 +2084,11 @@ function DockOverlay({ const root = blockRef.current; const el = scrollRef.current; if (!root || !el) return; - const canScroll = (n: HTMLElement, dir: number) => { - if (n.scrollHeight - n.clientHeight < 2) return false; // not scrollable - return dir < 0 ? n.scrollTop > 0 : n.scrollTop < n.scrollHeight - n.clientHeight - 0.5; - }; - const onWheel = (e: WheelEvent) => { - if (e.deltaY === 0) return; - for (let n = e.target as HTMLElement | null; n && n !== root; n = n.parentElement) { - if (n instanceof HTMLElement && canScroll(n, e.deltaY)) return; // native - } - e.preventDefault(); - el.scrollTop += e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1); - }; + const onWheel = (e: WheelEvent) => forwardDockWheel(e, root, el); root.addEventListener("wheel", onWheel, { passive: false }); return () => root.removeEventListener("wheel", onWheel); }, [scrollRef]); - // ── The reserve: the spacer's height, reported upward ── - // Grows immediately (typing, new variants). NEVER drops while a question - // is open — a shrinking reserved tail is scroll geometry and the browser - // clamp would pin a nearby learner to the bottom (the old oscillation). - // In chat mode drops apply (the composer's typing shrink is today's - // harmless in-flow behavior; nothing auto-expands there). - const reserveRef = useRef(0); - const blockRef = useRef(null); - const offer = (blockH: number) => { - const next = blockH + GRADIENT_H; - if (next <= reserveRef.current + 0.5 && pendingRef.current) return; // fold immunity - // Reserve GROWTH is the phantom-scroll SOURCE (flicker round, - // 2026-09-26): the spacer's height lands in this commit, the smooth - // catch-up lags it by the growth amount, and the position machine - // used to read that lag as the learner leaving — form → bar → form - // (the dictation flush, reproduced d≈152 > COLLAPSE_DIST). Stamp the - // mute right where the spacer grows: it covers the scroll events of - // THIS growth no matter how long after the field stretched (the - // field grows on its own render; the offer lands on the NEXT - // DockOverlay render — ~1s apart live). Shrinks never stamp (the - // fold's own give-back must not self-mute the re-expand). - if (shouldStampGeometryMute(next, reserveRef.current, pendingRef.current)) geometryAtRef.current = Date.now(); - reserveRef.current = next; - onReserve(next); - }; - // Explicit expand (bar click): the smooth scroll to the bottom takes // hundreds of ms, during which the position is still legitimately inside // the collapse zone — the machine would instantly re-collapse the dock @@ -2222,11 +2190,6 @@ function DockOverlay({ if (b) offer(b.getBoundingClientRect().height); }; - const mode = pending - ? `question:${pending.toolCallId}` - : practice - ? `practice:${practice.toolCallId}` - : "chat"; // The opening variant of a session appears instantly — the fade/rise is // reserved for mid-session transitions. const initialModeRef = useRef(mode); @@ -2234,11 +2197,9 @@ function DockOverlay({ // Height morph: animate the shell from its previous height to its new one // whenever the variant (chat ↔ question ↔ collapsed bar) changes. Typing // growth keeps the same stateKey and is NOT animated. - const shellRef = useRef(null); // The fold's CONTENT fade (owner round, 2026-10-05): the shell height // morphs, but the bar↔form content used to hard-swap. A WAAPI fade/rise // on the toggle — no remount, so the question form's state survives. - const contentRef = useRef(null); const prevExpandedForFadeRef = useRef(expanded); useIsoLayoutEffect(() => { if (prevExpandedForFadeRef.current === expanded) return; @@ -2286,8 +2247,10 @@ function DockOverlay({ return (
-
+
+
+
{setupCta} {/* The queued-steer indicator (owner spec 2026-10-03): quiet one-liners above the shell - right-aligned to where its corner @@ -2316,11 +2279,13 @@ function DockOverlay({ /> )}
+ {/* Include descendant margins (notably practice thinking's + bottom margin) in the natural content measurement. */}
+ ref={contentRef} + key={mode} + className={mode === initialModeRef.current ? "flow-root" : "flow-root dock-variant"} + > {pending ? (
+
+
{/* The stats row: beneath the composer/question shell, not flush to the screen bottom — static height, outside the morphing shell so it never joins a height animation. */} diff --git a/components/dock-height.ts b/components/dock-height.ts new file mode 100644 index 0000000..dd6f2ac --- /dev/null +++ b/components/dock-height.ts @@ -0,0 +1,157 @@ +"use client"; + +import { useCallback, useEffect, useLayoutEffect, type RefObject } from "react"; + +const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + +export interface DockHeightMeasurements { + columnHeight: number; + /** Stats/toolbar and block padding, outside the scrolling viewport. */ + outsideHeight: number; + /** Everything in the viewport except the animated shell. */ + beforeShellHeight: number; + shellBorderHeight: number; + /** Natural content height, not the shell's animated/clamped height. */ + contentHeight: number; + /** Null when collapsed, in chat mode, or showing malformed input. */ + questionHeight: number | null; + questionPaddingHeight: number; +} + +export function dockHeightBudget(m: DockHeightMeasurements) { + const available = Math.max(m.columnHeight - 32, 0); + const viewportMaxHeight = Math.max(available - m.outsideHeight, 0); + const shellMaxHeight = Math.max(viewportMaxHeight - m.beforeShellHeight, 0); + const naturalHeight = m.beforeShellHeight + m.shellBorderHeight + m.contentHeight; + const questionMaxHeight = m.questionHeight === null + ? null + : Math.max(viewportMaxHeight - (naturalHeight - m.questionHeight), 0); + // A cap smaller than the question's padding leaves no content viewport. + // Deliberately uncap the question and scroll the whole content instead; + // do not confuse a measured zero with an unmeasured CSS fallback. + const scrollWholeContent = questionMaxHeight === null + ? naturalHeight > viewportMaxHeight + : questionMaxHeight <= m.questionPaddingHeight; + return { viewportMaxHeight, shellMaxHeight, questionMaxHeight, scrollWholeContent }; +} + +interface DockHeightElements { + column: HTMLElement; + block: HTMLElement; + viewport: HTMLElement; + body: HTMLElement; + shell: HTMLElement; + content: HTMLElement; +} + +const pixels = (value: string) => Number.parseFloat(value) || 0; + +/** Read natural geometry before writing any constraints. Cancel the shell's + * animated height out of both ancestor measurements, then substitute its + * natural content and borders. This stays valid during a WAAPI height morph + * and when the outer viewport is already capped. */ +export function syncDockHeight(elements: DockHeightElements, expanded: boolean) { + const { column, block, viewport, body, shell, content } = elements; + // QuestionDock keeps a laid-out form in an invisible h-0 wrapper; + // PracticeDock removes its form. Neither hidden form is a height input. + const question = expanded ? content.querySelector("[data-dock-scroll-area]") : null; + const height = (el: HTMLElement) => el.getBoundingClientRect().height; + const shellStyle = getComputedStyle(shell); + const questionStyle = question ? getComputedStyle(question) : null; + const budget = dockHeightBudget({ + columnHeight: column.clientHeight, + outsideHeight: Math.max(height(block) - height(viewport), 0), + beforeShellHeight: Math.max(height(body) - height(shell), 0), + shellBorderHeight: pixels(shellStyle.borderTopWidth) + pixels(shellStyle.borderBottomWidth), + contentHeight: height(content), + questionHeight: question ? height(question) : null, + questionPaddingHeight: questionStyle + ? pixels(questionStyle.paddingTop) + pixels(questionStyle.paddingBottom) + : 0, + }); + if (budget.questionMaxHeight === null) block.style.removeProperty("--dock-max-h"); + else block.style.setProperty("--dock-max-h", budget.scrollWholeContent ? "none" : `${budget.questionMaxHeight}px`); + viewport.style.maxHeight = `${budget.viewportMaxHeight}px`; + // Overflow is visible in normal layouts: keep the shell's shadow intact. + // The stats/model toolbar is a sibling, never inside this overflow box. + viewport.style.overflowY = budget.scrollWholeContent ? "auto" : "visible"; + // Clamp animated starting heights too, so a previous tall variant cannot + // paint above the column while morphing into a constrained layout. + shell.style.maxHeight = budget.scrollWholeContent ? "none" : `${budget.shellMaxHeight}px`; + return budget; +} + +interface DockHeightRefs { + scrollRef: RefObject; + blockRef: RefObject; + viewportRef: RefObject; + bodyRef: RefObject; + shellRef: RefObject; + contentRef: RefObject; +} + +export function useDockHeight({ + scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef, + expanded, enabled, mode, onScrollbarInset, onHeight, +}: DockHeightRefs & { + expanded: boolean; + enabled: boolean; + /** Reconnect observers when the keyed content node/scroll area changes. */ + mode: string; + onScrollbarInset: (inset: number) => void; + /** Settled visible block height, fed through the existing reserve policy. */ + onHeight: (height: number) => void; +}) { + const measure = useCallback(() => { + const column = scrollRef.current; + const block = blockRef.current; + const viewport = viewportRef.current; + const body = bodyRef.current; + const shell = shellRef.current; + const content = contentRef.current; + if (!column || !block || !viewport || !body || !shell || !content) return; + onScrollbarInset(Math.round((column.offsetWidth - column.clientWidth) / 2)); + if (enabled) syncDockHeight({ column, block, viewport, body, shell, content }, expanded); + else { + block.style.removeProperty("--dock-max-h"); + viewport.style.removeProperty("max-height"); + viewport.style.removeProperty("overflow-y"); + shell.style.removeProperty("max-height"); + } + // ResizeObserver runs even when only a child field changed and React + // did not rerender DockOverlay. Keep its transcript reserve current, + // but never report a mid-morph height. + if (shell.getAnimations().length === 0) onHeight(block.getBoundingClientRect().height); + }, [scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef, expanded, enabled, onScrollbarInset, onHeight]); + + // Imperative constraints land before the overlay's later layout effect + // measures its morph target/reserve, and before paint (including reveal). + useIsoLayoutEffect(measure); + useIsoLayoutEffect(() => { + const observer = new ResizeObserver(measure); + for (const el of [scrollRef.current, blockRef.current, viewportRef.current, bodyRef.current, contentRef.current]) { + if (el) observer.observe(el); + } + const question = expanded ? contentRef.current?.querySelector("[data-dock-scroll-area]") : null; + if (question) observer.observe(question); + window.addEventListener("resize", measure); + return () => { + observer.disconnect(); + window.removeEventListener("resize", measure); + }; + }, [measure, mode, expanded, scrollRef, blockRef, viewportRef, bodyRef, contentRef]); +} + +/** Overlay and transcript are siblings. Keep native scrolling in an actual + * scroll container (including the short-window viewport), then forward only + * at its edge. overflow:hidden/visible boxes cannot consume the wheel. */ +export function forwardDockWheel(event: WheelEvent, root: HTMLElement, transcript: HTMLElement) { + if (event.deltaY === 0) return; + for (let n = event.target as HTMLElement | null; n && n !== root; n = n.parentElement) { + if (!(n instanceof HTMLElement) || !/^(auto|scroll)$/.test(getComputedStyle(n).overflowY)) continue; + if (n.scrollHeight - n.clientHeight < 2) continue; + if (event.deltaY < 0 ? n.scrollTop > 0 : n.scrollTop < n.scrollHeight - n.clientHeight - 0.5) return; + } + event.preventDefault(); + transcript.scrollTop += event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? transcript.clientHeight : 1); +} diff --git a/tests/dock-height.test.tsx b/tests/dock-height.test.tsx new file mode 100644 index 0000000..e6d9d3d --- /dev/null +++ b/tests/dock-height.test.tsx @@ -0,0 +1,259 @@ +import React, { act, useLayoutEffect, useRef, useState } from "react"; +(globalThis as Record).React = React; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createRoot } from "react-dom/client"; +import { parseHTML } from "linkedom"; +import { QuestionDock } from "../components/question-dock.tsx"; +import { PracticeDock } from "../components/practice-dock.tsx"; + +const dom = parseHTML(""); +Object.assign(globalThis, { + window: dom.window, document: dom.document, HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, +}); +const observers: TestObserver[] = []; +class TestObserver { + elements = new Set(); + disconnected = false; + constructor(readonly callback: () => void) { observers.push(this); } + observe(el: Element) { this.elements.add(el); } + disconnect() { this.disconnected = true; this.elements.clear(); } +} +Object.assign(globalThis, { + ResizeObserver: TestObserver, + getComputedStyle: (el: HTMLElement) => ({ + borderTopWidth: el.dataset.part === "shell" ? "1px" : "0px", + borderBottomWidth: el.dataset.part === "shell" ? "1px" : "0px", + paddingTop: el.hasAttribute("data-dock-scroll-area") ? "12px" : "0px", + paddingBottom: "0px", + overflowY: el.style.overflowY || (el.classList.contains("overflow-y-auto") ? "auto" : "visible"), + }), +}); +// Import after installing window so the hook uses its browser layout effect. +const { dockHeightBudget, syncDockHeight, useDockHeight, forwardDockWheel } = await import("../components/dock-height.ts"); + +const normal = { + columnHeight: 700, outsideHeight: 60, beforeShellHeight: 0, + shellBorderHeight: 2, contentHeight: 800, questionHeight: 600, + questionPaddingHeight: 12, +}; + +describe("dockHeightBudget — whole dock, not just its question", () => { + it("subtracts answer controls, toolbar, borders and breathing gap", () => { + assert.deepEqual(dockHeightBudget(normal), { + viewportMaxHeight: 608, shellMaxHeight: 608, questionMaxHeight: 406, scrollWholeContent: false, + }); + }); + it("accounts for growing answer fields and the separate reading draft", () => { + const result = dockHeightBudget({ ...normal, beforeShellHeight: 170, contentHeight: 880 }); + assert.equal(result.questionMaxHeight, 156); + assert.equal(result.shellMaxHeight, 438); + }); + it("preserves a computed zero and deliberately selects whole-content scrolling", () => { + const result = dockHeightBudget({ ...normal, columnHeight: 292 }); + assert.equal(result.questionMaxHeight, 0); + assert.equal(result.scrollWholeContent, true); + }); + it("switches before padding would leave a zero-sized content viewport, without an invented minimum", () => { + assert.equal(dockHeightBudget({ ...normal, columnHeight: 306 }).scrollWholeContent, true); + assert.equal(dockHeightBudget({ ...normal, columnHeight: 307 }).scrollWholeContent, false); + }); + it("never creates negative caps, even if the toolbar exceeds the column", () => { + const result = dockHeightBudget({ ...normal, columnHeight: 20 }); + assert.equal(result.viewportMaxHeight, 0); + assert.equal(result.shellMaxHeight, 0); + assert.equal(result.questionMaxHeight, 0); + assert.equal(result.scrollWholeContent, true); + }); + it("collapsed bars use their visible height, and a tall reading draft can still scroll", () => { + assert.equal(dockHeightBudget({ ...normal, questionHeight: null, contentHeight: 42 }).scrollWholeContent, false); + assert.equal(dockHeightBudget({ ...normal, questionHeight: null, contentHeight: 42, beforeShellHeight: 650 }).scrollWholeContent, true); + }); +}); + +// These mocked dimensions test the DOM/lifecycle seam, not real CSS layout. +// Live-browser checks separately exercise actual field sizing and WAAPI. +const sizes: Record = {}; +let animating = false; +dom.window.HTMLElement.prototype.getAnimations = () => animating ? [{} as Animation] : []; +const resetSizes = () => Object.assign(sizes, { column: 700, block: 862, viewport: 802, body: 802, shell: 802, content: 800, question: 600 }); +const originalRect = dom.window.HTMLElement.prototype.getBoundingClientRect; +dom.window.HTMLElement.prototype.getBoundingClientRect = function () { + const part = this.hasAttribute("data-dock-scroll-area") ? "question" : this.dataset.part; + if (part && part in sizes) return { height: sizes[part]!, width: 700, x: 0, y: 0, top: 0, left: 0, bottom: sizes[part]!, right: 700, toJSON() {} }; + return originalRect.call(this); +}; +Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", { configurable: true, get() { return this.dataset.part === "column" ? sizes.column : Number(this.dataset.clientHeight ?? 0); } }); +Object.defineProperty(dom.window.HTMLElement.prototype, "clientWidth", { configurable: true, get() { return this.dataset.part === "column" ? 680 : 700; } }); +Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get() { return 700; } }); +Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", { configurable: true, get() { return Number(this.dataset.scrollHeight ?? 0); } }); + +const ignoreHeight = () => {}; +function Fixture({ expanded = true, practice = false, enabled = true, inspected, onHeight = ignoreHeight }: { + expanded?: boolean; practice?: boolean; enabled?: boolean; inspected?: (block: HTMLDivElement) => void; + onHeight?: (height: number) => void; +}) { + const scrollRef = useRef(null); + const blockRef = useRef(null); + const viewportRef = useRef(null); + const bodyRef = useRef(null); + const shellRef = useRef(null); + const contentRef = useRef(null); + const [inset, setInset] = useState(0); + useDockHeight({ scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef, + expanded, enabled, mode: practice ? "practice:p1" : "question:q1", onScrollbarInset: setInset, onHeight }); + // Like DockOverlay's later morph/reserve layout effect: constraints must + // already be applied in this same commit, not an after-paint correction. + useLayoutEffect(() => { if (blockRef.current) inspected?.(blockRef.current); }); + return <>
+
+
+ {practice ? {}} onSubmit={() => {}} /> + : {}} onSubmit={() => {}} />} +
+
+
; +} + +async function withFixture(run: (host: HTMLElement, render: (props?: React.ComponentProps) => Promise) => Promise) { + resetSizes(); + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); + const render = async (props = {}) => { await act(async () => root.render()); }; + try { await run(host, render); } + finally { await act(async () => root.unmount()); host.remove(); } +} +const part = (host: HTMLElement, name: string) => host.querySelector(`[data-part="${name}"]`)!; + +describe("measured question/practice DOM and observer lifecycle", () => { + it("applies constraints before the morph layout effect and handles classic scrollbar insets", async () => { + await withFixture(async (host, render) => { + await render({ inspected: block => assert.equal(block.style.getPropertyValue("--dock-max-h"), "406px") }); + assert.equal(part(host, "block").dataset.inset, "10"); + assert.equal(part(host, "viewport").style.overflowY, "visible"); + assert.ok(!part(host, "viewport").contains(host.querySelector('[data-testid="toolbar"]'))); + }); + }); + it("excludes the animated/clamped shell height rather than treating it as answer chrome", async () => { + await withFixture(async (host, render) => { + await render(); + Object.assign(sizes, { block: 160, viewport: 100, body: 100, shell: 100 }); + await act(async () => observers.at(-1)!.callback()); + assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px"); + }); + }); + it("ignores the still-mounted hidden teaching form and sizes it synchronously on reveal", async () => { + await withFixture(async (host, render) => { + Object.assign(sizes, { block: 104, viewport: 44, body: 44, shell: 44, content: 42, question: 2000 }); + await render({ expanded: false }); + assert.ok(host.querySelector('[data-dock-scroll-area]'), "teaching form remains mounted"); + assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h") || "", ""); + assert.equal(part(host, "viewport").style.overflowY, "visible"); + resetSizes(); + await render({ expanded: true, inspected: block => assert.equal(block.style.getPropertyValue("--dock-max-h"), "406px") }); + }); + }); + it("reconnects to practice's newly mounted question after collapsing and revealing", async () => { + await withFixture(async (host, render) => { + Object.assign(sizes, { block: 104, viewport: 44, body: 44, shell: 44, content: 42 }); + await render({ practice: true, expanded: false }); + const collapsedObserver = observers.at(-1)!; + assert.equal(host.querySelector('[data-dock-scroll-area]'), null); + resetSizes(); + await render({ practice: true, expanded: true }); + assert.equal(collapsedObserver.disconnected, true); + assert.ok(observers.at(-1)!.elements.has(host.querySelector('[data-dock-scroll-area]')!)); + assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px"); + }); + }); + it("intentionally uncaps the question for a short window, then restores the measured cap on growth", async () => { + await withFixture(async (host, render) => { + sizes.column = 292; + await render(); + assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "none"); + assert.equal(part(host, "viewport").style.overflowY, "auto"); + assert.equal(part(host, "viewport").style.maxHeight, "200px"); + assert.equal(part(host, "shell").style.maxHeight, "none"); + sizes.column = 700; + await act(async () => observers.at(-1)!.callback()); + assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px"); + assert.equal(part(host, "viewport").style.overflowY, "visible"); + }); + }); + it("removes constraints in composer mode and disconnects observers on unmount", async () => { + let last: TestObserver; + await withFixture(async (host, render) => { + await render(); + await render({ enabled: false }); + assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h") || "", ""); + assert.equal(part(host, "shell").style.maxHeight || "", ""); + last = observers.at(-1)!; + }); + assert.equal(last!.disconnected, true); + }); + it("reports child-only resize heights to the reserve, but never mid-morph values", async () => { + await withFixture(async (_host, render) => { + const heights: number[] = []; + await render({ onHeight: h => heights.push(h) }); + assert.equal(heights.at(-1), 862); + animating = true; + try { + sizes.block = 900; + await act(async () => observers.at(-1)!.callback()); + assert.equal(heights.at(-1), 862, "animated block height was not reserved"); + } finally { animating = false; } + await act(async () => observers.at(-1)!.callback()); + assert.equal(heights.at(-1), 900, "settled child resize reaches the reserve without a parent render"); + }); + }); + it("reconnects to a new keyed mode even when expansion is unchanged", async () => { + await withFixture(async (host, render) => { + await render(); + const previous = observers.at(-1)!; + const oldContent = part(host, "content"); + await render({ practice: true }); + assert.equal(previous.disconnected, true); + assert.notEqual(part(host, "content"), oldContent); + assert.ok(observers.at(-1)!.elements.has(part(host, "content"))); + }); + }); + it("keeps the DOM synchronizer deterministic when observed again with the same dimensions", async () => { + await withFixture(async (host, render) => { + await render(); + const elements = { column: part(host, "column"), block: part(host, "block"), viewport: part(host, "viewport"), + body: part(host, "body"), shell: part(host, "shell"), content: part(host, "content") }; + assert.deepEqual(syncDockHeight(elements, true), syncDockHeight(elements, true)); + }); + }); +}); + +describe("wheel routing — question, short-window content, then transcript", () => { + it("keeps native wheel input in the question and then the whole-content viewport", () => { + const root = document.createElement("div"); + root.innerHTML = '
text
'; + const viewport = root.firstElementChild as HTMLElement; + const question = viewport.firstElementChild as HTMLElement; + const transcript = document.createElement("div"); transcript.scrollTop = 100; + for (const el of [viewport, question]) { el.dataset.scrollHeight = "400"; el.dataset.clientHeight = "100"; el.scrollTop = 50; } + const wheel = (deltaY: number, deltaMode = 0) => { + let prevented = false; + forwardDockWheel({ target: question.firstElementChild, deltaY, deltaMode, preventDefault() { prevented = true; } } as unknown as WheelEvent, root, transcript); + return prevented; + }; + assert.equal(wheel(-20), false, "question can scroll up"); + question.scrollTop = 0; + assert.equal(wheel(-20), false, "whole content can scroll up when question is at its edge"); + viewport.scrollTop = 0; + assert.equal(wheel(-20), true); + assert.equal(transcript.scrollTop, 80); + question.scrollTop = viewport.scrollTop = 300; + assert.equal(wheel(1, 1), true); + assert.equal(transcript.scrollTop, 96, "line-mode wheel is normalized"); + viewport.style.overflowY = "visible"; viewport.scrollTop = 50; + assert.equal(wheel(-20), false, "question still consumes upward movement"); + question.style.overflowY = "hidden"; + assert.equal(wheel(-20), true, "hidden and visible boxes are not scroll containers"); + assert.equal(wheel(0), false); + }); +}); From 419dcf1ba29749ed3491b627b780f5c94a08713b Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 16:12:13 +0200 Subject: [PATCH 36/87] Restore exclusive provider-first setup recovery --- components/chat.tsx | 51 ++++++------ components/home.tsx | 27 +++--- components/new-session.tsx | 26 +++--- components/setup-cta.tsx | 32 +++++++- components/setup-dialog.tsx | 45 +++++----- components/stats-bar.tsx | 11 ++- tests/model-catalog-setup.test.tsx | 127 ++++++++++++++++++++++++----- 7 files changed, 222 insertions(+), 97 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index cef946d..952abf5 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -5,7 +5,7 @@ import { useChatSession } from "@/components/session"; import { visibleQueuedSteers } from "@/components/session-core"; import { SetupDialog } from "@/components/setup-dialog"; import { Hint } from "@/components/ui/hint"; -import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; +import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta"; import { useStickToBottomContext, type StickToBottomContext } from "use-stick-to-bottom"; import { Conversation, @@ -78,8 +78,6 @@ import { } from "@/src/core"; import { ReadingThinkingField } from "@/components/reading-thinking"; import { currentUserThinkingOpen, setAllUserThinkingOpen } from "@/components/thinking-block"; -import { slotsConfigured, type ModelsSettings } from "@/src/core"; -import { useSettingsSaved } from "@/components/settings-signal"; /** One width for everything: transcript, cards, and the bottom blob share this column. */ /** @@ -555,30 +553,18 @@ export function Chat({ textRef.current = v; setText(v); }, []); - // The BYOK setup flow (owner, 2026-09-22): the persistent CTA and the - // refusal interception share one SetupDialog mount. `configured` is - // seeded server-side (slotsConfigured on the record) and flips on Save. - const [configured, setConfigured] = useState(initialConfigured); - // The zero-keys truth (zero-keys wave): no provider key = the pill - // takes precedence over the models CTA (keys come first in the chain). - // Reads once on mount, re-reads on the "keys" signal. - const zeroKeys = useZeroKeys(); - // Cross-surface freshness (fix wave): another surface's models write — - // setup save, composer pick, a settings write — re-reads the record so - // the setup CTA appears/disappears live (it used to lag until reload). - useSettingsSaved((section) => { - if (section !== "models") return; - fetch("/api/settings") - .then((r) => r.json() as Promise<{ settings?: { models?: ModelsSettings } | null } | null>) - .then((body) => setConfigured(slotsConfigured(body?.settings?.models))) - .catch(() => {}); - }); + // Saved slot completeness is live, but the session's selected route + // comes from its own ordinary model control, never today's Strong pin. + const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured); + const catalogRef = useRef(catalog); + catalogRef.current = catalog; + const [selectedModel, setSelectedModel] = useState(); const [setupOpen, setSetupOpen] = useState(false); /** The last explicitly-sent draft (composer send): the * refusal flow restores it into the composer, where it waits for the * learner to re-send (Save never auto-sends, owner 2026-10-09). * Auto-continuations (probe answers) never set it — their refusal - * opens the dialog with no replay. */ + * opens recovery with no replay. */ const draftRef = useRef(null); // THE BYOK REFUSAL (owner, 2026-09-22): a send that lands the server's @@ -594,8 +580,9 @@ export function Chat({ if (!error) return; const missing = parseModelsRefusal(error.message); // The key-refusal twin (zero-keys wave): a pinned model whose - // provider key is gone gets the SAME treatment — revert-draft and - // open setup (the dialog derives to its first-key stage on its own). + // provider key is gone also restores the draft. With another provider + // connected, repair belongs to the ordinary current-session picker; + // otherwise setup derives its exclusive provider/loading stage. const keyRefused = parseProviderKeyRefusal(error.message); if (!missing && !keyRefused) return; // a real error keeps the honest banner // ORDER MATTERS: clearError() flips this effect's own dep (`error`) @@ -615,7 +602,8 @@ export function Chat({ draftRef.current = null; } clearError(); - setSetupOpen(true); + if (keyRefused && catalogRef.current?.providers.some((p) => p.keyPresent)) openModelPicker(); + else setSetupOpen(true); })(); return () => { cancelled = true; @@ -1146,6 +1134,14 @@ export function Chat({ if (busy || pending || compacting) return; const t = textRef.current.trim(); if (!t) return; + if (zeroKeys) { + setSetupOpen(true); + return; + } + if (selectedModelUnavailable(catalog, selectedModel)) { + openModelPicker(); + return; // No optimistic send and no change to the learner's draft. + } draftRef.current = t; // the refusal flow's preserve-and-replay seam applyText(""); void send({ type: "text", text: t }); @@ -1605,6 +1601,7 @@ export function Chat({ compactionRunning={compacting} onSubmitThinking={submitThinking} chatId={chatId} + onModelChange={setSelectedModel} plan={hasPlan ? : undefined} onOpenPlan={() => setPlanOpen(true)} requiresReveal={requiresReveal} @@ -1708,7 +1705,6 @@ export function Chat({ onClose={() => setSetupOpen(false)} onDone={() => { setSetupOpen(false); - setConfigured(true); // Save only saves (owner ruling 2026-10-09): the draft went // home into the composer at refusal time and STAYS there — // the learner sends it themselves. @@ -1843,6 +1839,7 @@ function DockOverlay({ composer, onSubmitAnswer, chatId, + onModelChange, onReserve, plan, onOpenPlan, @@ -1879,6 +1876,7 @@ function DockOverlay({ onSubmitAnswer: (output: QuestionAnswerData) => void; /** The model control's pass-through (the stats bar hosts it). */ chatId: string; + onModelChange: (model: string | undefined) => void; /** Reports the dock's settled height (block + gradient) — ChatView's * dockReserve state feeds the transcript's spacer from it. */ onReserve: (h: number) => void; @@ -2331,6 +2329,7 @@ function DockOverlay({ pricing={pricing} dictationSlot={dictationSlot} chatId={chatId} + onModelChange={onModelChange} /> {/* The scroll-down disc (moved here from the Conversation, owner refinement round 2026-09-24): anchored to the BLOCK's diff --git a/components/home.tsx b/components/home.tsx index 4b16d62..6644bc6 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -7,7 +7,7 @@ import { GraphView } from "@/components/graph-view"; import { MinimalHome } from "@/components/minimal-home"; import { SetupDialog } from "@/components/setup-dialog"; import { ComposerActions, NodePickerControl } from "@/components/composer-actions"; -import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; +import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta"; import { DictationBar, joinDictation, useDictation } from "@/components/dictation"; import { useFieldCycle } from "@/components/field-cycle"; import { SidebarToggle } from "@/components/sidebar"; @@ -41,15 +41,8 @@ export function Home({ // start until the user has picked all three slots — no defaults for // 33 providers. The dock shows the CTA while unconfigured; submitting // a topic opens the setup dialog WITH THE DRAFT PRESERVED (Save - // stores the slots and the queued message sends). - const [configured, setConfigured] = useState(initialConfigured); - // The zero-keys truth (zero-keys wave): no provider key = the pill - // takes precedence over the models CTA (keys come first in the chain). - const zeroKeys = useZeroKeys(); - // The strong slot's keyless truth (pre-send key guards, owner interlude - // 2026-09-25): keys may exist generally while the STRONG slot's pinned - // provider has none — the same skip-everything treatment on send. - const strongKeyless = useStrongKeyless(); + // stores slots only; the learner sends the message themselves). + const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured); const [setupOpen, setSetupOpen] = useState(false); // Voice dictation (STT wave, 2026-09-17): finals append at the end of the // topic draft; the strip lives in the stats bar's middle slot. @@ -123,13 +116,18 @@ export function Home({ // models case guards BEFORE creation; the key cases must too — a // refused first send would otherwise leave an orphan error-shell // chat in the sidebar (the 503 lands only after POST /api/chats - // already created the record). Same skip-everything-and-open-setup - // treatment; the server's 503 gate remains the belt for /chat/[id] - // sessions. - if (zeroKeys || strongKeyless) { + // already created the record). Zero providers opens connection setup; + // an unavailable selection with another provider opens its ordinary + // picker. The server's admission gate remains the belt. + if (zeroKeys) { setSetupOpen(true); return; } + const strong = catalog?.slots.strong?.model; + if (selectedModelUnavailable(catalog, strong ? `${strong.provider}:${strong.id}` : undefined)) { + openModelPicker(); + return; // Preserve the draft and never create an orphan session. + } setBusy(true); try { // The map context (owner, 2026-10-04): picks in EITHER mode ride @@ -326,7 +324,6 @@ export function Home({ open={setupOpen} onClose={() => setSetupOpen(false)} onDone={() => { - setConfigured(true); setSetupOpen(false); // Save only saves: the draft waits in the composer. }} diff --git a/components/new-session.tsx b/components/new-session.tsx index c2a0930..2672340 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -8,7 +8,7 @@ import { DictationBar, joinDictation, useDictation } from "@/components/dictatio import { useFieldCycle } from "@/components/field-cycle"; import { SidebarToggle } from "@/components/sidebar"; import { SetupDialog } from "@/components/setup-dialog"; -import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; +import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta"; import { StatsBar } from "@/components/stats-bar"; import { ChainTableOverlay } from "@/components/chain-table.tsx"; import { ComposerActions, NodePickerControl } from "@/components/composer-actions"; @@ -275,14 +275,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi // 33 providers, chats cannot start. The draft stays in the textarea; // Save stores the slots and never sends (owner ruling 2026-10-09) — // the learner sends the draft themselves. - const [configured, setConfigured] = useState(initialConfigured); - // The zero-keys truth (zero-keys wave): no provider key = the pill - // takes precedence over the models CTA (keys come first in the chain). - const zeroKeys = useZeroKeys(); - // The strong slot's keyless truth (pre-send key guards, owner interlude - // 2026-09-25): keys may exist generally while the STRONG slot's pinned - // provider has none — the same skip-everything treatment on send. - const strongKeyless = useStrongKeyless(); + const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured); const [setupOpen, setSetupOpen] = useState(false); const send = useCallback(() => { @@ -295,12 +288,18 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi // The pre-send KEY guards (owner interlude, no-orphan-chats): guard // BEFORE startDraftSession creates the record — a refused first // send would otherwise leave an orphan error-shell chat in the - // sidebar. Same skip-everything-and-open-setup treatment; the - // server's 503 gate remains the belt for /chat/[id] sessions. - if (zeroKeys || strongKeyless) { + // sidebar. Zero providers opens connection setup; an unavailable + // selection with another provider opens the ordinary Strong picker. + // The server's admission gate remains the belt. + if (zeroKeys) { setSetupOpen(true); return; } + const strong = catalog?.slots.strong?.model; + if (selectedModelUnavailable(catalog, strong ? `${strong.provider}:${strong.id}` : undefined)) { + openModelPicker(); + return; // The learner replaces it explicitly, then sends again. + } setBusy(true); void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined) .then((target) => { @@ -308,7 +307,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi else setBusy(false); }) .catch(() => setBusy(false)); - }, [busy, router, configured, zeroKeys, strongKeyless]); + }, [busy, router, configured, zeroKeys, catalog]); return ( <> @@ -369,7 +368,6 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi open={setupOpen} onClose={() => setSetupOpen(false)} onDone={() => { - setConfigured(true); setSetupOpen(false); // Save only saves (owner ruling 2026-10-09): the draft // waits in the composer; the learner sends it. diff --git a/components/setup-cta.tsx b/components/setup-cta.tsx index de59b98..299715d 100644 --- a/components/setup-cta.tsx +++ b/components/setup-cta.tsx @@ -15,9 +15,10 @@ * its own save), the same re-read pattern chat.tsx uses for "models". */ -import type { AuthKeyStatus } from "@/src/core"; +import { useEffect, useState } from "react"; +import { MODEL_SLOTS, type AuthKeyStatus } from "@/src/core"; import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section"; -import { useModelCatalog } from "@/components/model-picker"; +import { useModelCatalog } from "@/components/model-catalog"; import type { ModelCatalogResponse } from "@/src/core"; /** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses @@ -32,6 +33,33 @@ export function hasProviderKey( return classifyKeys(statuses, providers).providerRows.some((s) => s.present); } +/** Completeness is saved identity, not current provider availability. */ +export function savedSlotsConfigured(slots: ModelCatalogResponse["slots"] | undefined): boolean { + return MODEL_SLOTS.every((slot) => !!slots?.[slot]?.model); +} + +/** Keep the last known completeness during connection invalidation, but + * follow ordinary slot repairs as soon as their shared snapshot arrives. */ +export function useModelSetup(initialConfigured: boolean) { + const { catalog } = useModelCatalog(); + const [lastConfigured, setLastConfigured] = useState(initialConfigured); + const configured = catalog ? savedSlotsConfigured(catalog.slots) : lastConfigured; + useEffect(() => { if (catalog) setLastConfigured(savedSlotsConfigured(catalog.slots)); }, [catalog]); + return { catalog, configured, zeroKeys: catalog ? !catalog.providers.some((p) => p.keyPresent) : false }; +} + +/** A known unavailable route needs an explicit replacement, never an + * automatic provider/model substitution. Unknown metadata defers to the + * server's admission gate instead of declaring a route disconnected. */ +export function selectedModelUnavailable(catalog: ModelCatalogResponse | undefined, selected: string | undefined): boolean { + return !!catalog && !!selected && !catalog.models.some((m) => `${m.provider}:${m.id}` === selected); +} + +/** Reuse the ordinary control (also opened by the existing keyboard action). */ +export function openModelPicker(): void { + window.dispatchEvent(new CustomEvent("lh:kbd-open-model-picker")); +} + /** The zero-keys truth for the calling page: TRUE only after a * successful read says no provider key exists (pending and failed * reads render nothing — the pill must never flash on a gap, and a diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx index 641898f..053f3eb 100644 --- a/components/setup-dialog.tsx +++ b/components/setup-dialog.tsx @@ -24,8 +24,9 @@ * Stage 2 — Strong → Cheap → Vision pickers over the FEDERATED catalog * (keyed providers only), the vision slot filtered to image-capable * models, in the app's shared dropdown language (ModelMenuList). - * Saved disconnected selections stay visible; Add provider opens the - * connection body when no provider remains. Refreshes preserve edits. + * Saved disconnected selections and edits are retained internally in + * the exclusive provider stage. Connecting with all slots already saved + * closes setup; otherwise the pickers require only the missing slots. * All three required (owner: no skip, no defaults); Save PUTs the * models section and reports onDone(models). * @@ -45,6 +46,7 @@ import { ModelMenuList } from "@/components/settings/model-menu"; import { MenuShell } from "@/components/menu-shell"; import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal"; import { useModelCatalog } from "@/components/model-catalog"; +import { savedSlotsConfigured } from "@/components/setup-cta"; import { Hint } from "@/components/ui/hint"; import { type ProviderKeyInfo } from "@/components/settings/keys-section"; import { ProviderMenuList } from "@/components/settings/model-menu"; @@ -247,12 +249,11 @@ export function SetupDialog(props: SetupDialogProps) { function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { const backdropDismiss = useBackdropDismiss(onClose); const { catalog, failed, refresh } = useModelCatalog(); - const [lastBody, setLastBody] = useState(catalog); - const body = catalog ?? lastBody; + const providerStageSeen = useRef(false); + const connectionCloseRequested = useRef(false); const [picked, setPicked] = useState(() => prefillFromSlots(catalog?.models ?? [], catalog?.slots)); const [levels, setLevels] = useState(() => prefillLevels(catalog)); const editedSlots = useRef(new Set()); - const [showKeys, setShowKeys] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [openMenu, setOpenMenu] = useState<{ slot: ModelSlotName; kind: "model" | "thinking" } | null>(null); @@ -273,8 +274,8 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { // The keys signal (zero-keys wave): keys-section saves/deletes AND // THIS dialog's own first-key save dispatch it; the fetch effect below - // re-runs on it, so saving a key flips the stage to the pickers with - // the widened catalog — no restart, no Settings round-trip. + // re-runs on it, so saving a key widens the catalog. Incomplete saved + // slots advance to pickers; complete saved slots just close setup. const [keysTick, setKeysTick] = useState(0); useSettingsSaved((section) => { if (section === "keys") setKeysTick((t) => t + 1); @@ -291,7 +292,6 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { useEffect(() => { if (!catalog) return; - setLastBody(catalog); const pre = prefillFromSlots(catalog.models, catalog.slots); const preLevels = prefillLevels(catalog); // Fresh metadata is always used, but a response cannot reset an @@ -310,9 +310,18 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { } return next; }); - if (catalog.providers.some((p) => p.keyPresent)) setShowKeys(false); }, [catalog]); + const stage = setupStage(catalog?.providers ?? null); + const closeAfterConnection = stage === "pickers" && providerStageSeen.current && savedSlotsConfigured(catalog?.slots); + useEffect(() => { + if (stage === "keys") providerStageSeen.current = true; + if (closeAfterConnection && !connectionCloseRequested.current) { + connectionCloseRequested.current = true; + onClose(); // Connection recovery is not a model-settings save. + } + }, [stage, closeAfterConnection, onClose]); + // Esc + outside-click — layered dismissal, closable WITHOUT // configuring (browsing stays possible; the gate is the send // interception, not this dialog). An open slot menu eats Esc first @@ -354,14 +363,16 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { }; }, [onClose, openMenu, keyMenuOpen]); - const hasSelections = Object.keys(picked).length > 0 || SLOT_ORDER.some((slot) => body?.slots?.[slot]?.model); - const stage = hasSelections ? "pickers" : setupStage(body ? body.providers : null); + // Do not briefly paint model pickers between successful connection and + // closing an already-configured setup. No saved model/thinking writes. + if (closeAfterConnection) return null; + const allPicked = SLOT_ORDER.every((s) => picked[s]); /** Stage 1's save: PUT the pasted key, dispatch the keys signal, and - * CLEAN the local screen — the signal re-fetch widens the catalog and - * the stage flips to the pickers on its own; the dialog stays mounted - * the whole way. Shape-gate 400s keep their user-facing line inline. */ + * CLEAN the local key field. The fresh catalog decides whether to + * close recovery or advance to the missing model slots. Shape-gate + * 400s keep their user-facing line inline. */ async function saveKey() { if (!keyProvider || !keyValue.trim() || keySaving) return; setKeySaving(true); @@ -465,10 +476,6 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {

Pick the model for each tier. All three are required before chats can start.

- {!body?.providers.some((p) => p.keyPresent) && <> - - {showKeys && keysBody} - } {SLOT_ORDER.map((slot) => { const entry = picked[slot]; const level = levels[slot]; @@ -481,7 +488,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
{SLOT_META[slot].label} - {!body?.slots?.[slot]?.model && !picked[slot] && ( + {!catalog?.slots?.[slot]?.model && !picked[slot] && ( void; /** The dictation strip (STT wave, 2026-09-17): the row's middle is dead * space between the session data and the model control — the mic * button lives there, and while recording the meter unfurls to fill @@ -55,6 +58,10 @@ export function StatsBar({ }) { const [open, setOpen] = useState(false); const [selectedModel, setSelectedModel] = useState(); + const modelChanged = useCallback((model: string | undefined) => { + setSelectedModel(model); + onModelChange?.(model); + }, [onModelChange]); // Same dropdown discipline as the model control (owner, df4433f treatment, // stats round 1): close on any pointerdown outside the bar or on Escape. const barRef = useRef(null); @@ -192,7 +199,7 @@ export function StatsBar({ {/* The model + thinking control — one controller for the conversation agents (prober/teacher); the record's `turns` above still folds the ACTUAL models billed, regardless of what the picker says. */} - +
); } diff --git a/tests/model-catalog-setup.test.tsx b/tests/model-catalog-setup.test.tsx index 43db670..4583863 100644 --- a/tests/model-catalog-setup.test.tsx +++ b/tests/model-catalog-setup.test.tsx @@ -9,6 +9,8 @@ import { AgentSection } from "../components/settings/agent-section.tsx"; import { providerKeyLabel, classifyKeys } from "../components/settings/keys-section.tsx"; import { useModelCatalog } from "../components/model-catalog.ts"; import { notifySettingsSaved } from "../components/settings-signal.ts"; +import { useModelSetup, savedSlotsConfigured, selectedModelUnavailable, openModelPicker } from "../components/setup-cta.tsx"; +import { StatsBar } from "../components/stats-bar.tsx"; import type { ModelCatalogEntry, ModelCatalogResponse, ModelsSettings } from "../src/core/index.ts"; const dom = parseHTML(""); @@ -40,7 +42,7 @@ function catalog(connected = true): ModelCatalogResponse { }; } function Reader() { snapshot = useModelCatalog(); return null; } -function setup() { return {}} onDone={() => {}} />; } +function setup(onClose = () => {}, onDone = (_models: ModelsSettings) => {}) { return ; } function installFetch() { requests = []; writes = []; globalThis.fetch = async (input, init) => { @@ -107,22 +109,27 @@ test("cached setup is immediately usable; fresh metadata/untouched slots update }); }); -test("disconnect invalidates choices, not saved selections or thinking; all-disconnected setup offers Add provider", async () => { - installFetch(); await warm(); await render(<>{setup()}); await answer(1, catalog()); +test("zero providers always means exclusive connection stage; reconnect with all saved slots only closes, even if every selection remains unavailable", async () => { + installFetch(); await warm(); + let closed = 0, completed = 0; + await render(<>{setup(() => { closed++; }, () => { completed++; })}); await answer(1, catalog()); await click("setup-trigger-strong"); - assert.ok(document.querySelector('[data-testid="model-option-claude-opus-5-5"]')); await act(async () => notifySettingsSaved("keys")); - assert.equal(document.querySelector('[data-testid="model-option-claude-opus-5-5"]'), null, "stale choices disappear before the fresh request resolves"); + assert.ok(element("setup-stage-loading")); + assert.equal(document.querySelector('[data-testid="model-option-claude-opus-5-5"]'), null); await answer(2, catalog(false)); - assert.equal(element("setup-trigger-strong").textContent, "model:free▾"); - assert.equal(element("setup-thinking-strong").textContent?.trim(), "low▾"); - assert.ok(element("setup-add-provider")); await click("setup-add-provider"); assert.ok(element("setup-stage-keys")); - await click("setup-thinking-strong"); await click("thinking-option-xhigh"); - assert.equal(element("setup-thinking-strong").textContent?.trim(), "xhigh▾"); - await click("setup-save"); assert.equal(writes[0]?.strong?.thinkingLevel, "xhigh"); assert.equal(writes[0]?.strong?.model?.id, a.id); + assert.ok(element("setup-stage-keys")); + assert.equal(document.querySelector('[data-testid="setup-stage-pickers"]'), null); + assert.equal(document.querySelector('[data-testid="setup-add-provider"]'), null); + assert.equal(document.querySelector('[data-testid="setup-save"]'), null); + await act(async () => notifySettingsSaved("keys")); + const other = catalog(false); other.providers = [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }]; + await answer(3, other); + assert.equal(closed, 1); assert.equal(completed, 0); assert.deepEqual(writes, []); + assert.equal(document.querySelector('[data-testid="setup-dialog"]'), null, "no intermediate picker frame after reconnect"); }); -test("cold reconnect checks saved pins before choosing the key stage, and reopening gets a new draft", async () => { +test("cold saved disconnected pins still enter the provider stage, without any model-selector frame", async () => { installFetch(); await warm(); await act(async () => notifySettingsSaved("keys")); const stages: Array = []; @@ -132,14 +139,47 @@ test("cold reconnect checks saved pins before choosing the key stage, and reopen return null; } await render(<>{setup()}); - assert.ok(element("setup-stage-loading")); await answer(1, catalog(false)); - assert.deepEqual(stages, ["setup-stage-loading", "setup-stage-pickers"]); - await click("setup-thinking-strong"); await click("thinking-option-xhigh"); - await render(); - await render(<>{setup()}); - assert.equal(element("setup-thinking-strong").textContent?.trim(), "low▾", "a reopened draft uses saved state, not the abandoned edit"); - await answer(2, catalog(false)); + assert.deepEqual(stages, ["setup-stage-loading", "setup-stage-keys"]); +}); + +for (const count of [0, 1, 2]) test(`connecting with ${count} saved slots only requires the missing slots; stale identities/thinking survive`, async () => { + installFetch(); const before = catalog(false); + if (count < 1) before.slots.strong = null; + if (count < 2) before.slots.cheap = null; + before.slots.vision = null; + if (before.slots.strong) before.slots.strong.thinkingLevel = "xhigh"; + await warm(before); + let closed = 0; await render(<>{setup(() => { closed++; })}); await answer(1, before); + assert.ok(element("setup-stage-keys")); + const connected = { ...before, models: [{ ...b, provider: "other" }], providers: [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }] }; + await act(async () => notifySettingsSaved("keys")); await answer(2, connected); + assert.ok(element("setup-stage-pickers")); assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null); assert.equal(closed, 0); + for (const slot of ["strong", "cheap", "vision"] as const) { + if (before.slots[slot]) continue; + await click(`setup-trigger-${slot}`); await click("model-option-claude-opus-5-5"); + } + if (count > 0) assert.equal(element("setup-thinking-strong").textContent?.trim(), "xhigh▾"); + await click("setup-save"); + for (const slot of ["strong", "cheap", "vision"] as const) { + if (before.slots[slot]) assert.deepEqual(writes[0]?.[slot], before.slots[slot]); + else assert.equal(writes[0]?.[slot]?.model?.provider, "other"); + } +}); + +test("a partial setup's model/thinking edits survive exclusive provider recovery, but reopening discards the abandoned draft", async () => { + installFetch(); const before = catalog(); before.slots.cheap = null; before.slots.vision = null; + await warm(before); await render(<>{setup()}); await answer(1, before); + await click("setup-trigger-strong"); await click("model-option-claude-opus-5-5"); + await click("setup-thinking-strong"); await click("thinking-option-medium"); + await act(async () => notifySettingsSaved("keys")); await answer(2, { ...before, models: [], providers: [] }); + assert.ok(element("setup-stage-keys")); assert.equal(document.querySelector('[data-testid="setup-stage-pickers"]'), null); + await act(async () => notifySettingsSaved("keys")); await answer(3, before); + assert.equal(element("setup-trigger-strong").textContent, "claude-opus-5-5▾"); + assert.equal(element("setup-thinking-strong").textContent?.trim(), "medium▾"); + await render(); await render(<>{setup()}); + assert.equal(element("setup-trigger-strong").textContent, "model:free▾"); + assert.equal(element("setup-thinking-strong").textContent?.trim(), "low▾"); await answer(4, before); }); test("Agent opens with cached thinking metadata; refresh and disconnect never clear saved levels", async () => { @@ -155,6 +195,55 @@ test("Agent opens with cached thinking metadata; refresh and disconnect never cl await click("thinking-option-xhigh"); assert.equal(changed?.strong?.thinkingLevel, "xhigh"); assert.equal(changed?.strong?.model?.id, a.id); }); +test("saved completeness follows ordinary repairs and survives temporary connection invalidation", async () => { + installFetch(); let state: ReturnType; + function StateReader() { state = useModelSetup(false); return null; } + await render(); await answer(0, catalog()); + assert.equal(state!.configured, true); + await act(async () => notifySettingsSaved("keys")); assert.equal(state!.configured, true); + const partial = catalog(false); partial.slots.vision = null; + await answer(1, partial); assert.equal(state!.configured, false); assert.equal(state!.zeroKeys, true); + await act(async () => notifySettingsSaved("models")); await answer(2, catalog(false)); + assert.equal(state!.configured, true); assert.equal(state!.zeroKeys, true, "saved is not connected"); +}); + +test("blocked session recovery opens its ordinary picker, never changes Strong or substitutes a model", async () => { + installFetch(); const available = { ...b, provider: "other" }; const body = catalog(); + body.models = [available]; body.providers = [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }]; + body.slots.strong = { model: { provider: "other", id: b.id }, thinkingLevel: "medium" }; + const globals: ModelsSettings = { strong: body.slots.strong }; + const session = { model: { provider: a.provider, id: a.id }, thinkingLevel: "xhigh" }; + const requestsOnly = globalThis.fetch; const sessionWrites: unknown[] = []; + globalThis.fetch = async (input, init) => { + if (input === "/api/settings") return Response.json({ settings: { models: globals } }); + if (input === "/api/chats/fixture") return Response.json({ settings: session }); + if (input === "/api/chats/fixture/settings") { const next = JSON.parse(String(init?.body)); sessionWrites.push(next); return Response.json({ settings: next }); } + return requestsOnly(input, init); + }; + await warm(body); let selected: string | undefined; + await render(<> { selected = id; }} />); + assert.equal(selected, `${a.provider}:${a.id}`, "current session does not inherit a different Strong pin"); + assert.equal(selectedModelUnavailable(body, selected), true); + assert.deepEqual(sessionWrites, []); + await act(async () => openModelPicker()); + assert.ok(element("model-menu")); assert.equal(document.querySelector('[data-testid="setup-dialog"]'), null); + assert.equal(document.querySelector('[data-testid="model-option-vendor_model_free"]'), null); + await click("model-option-claude-opus-5-5"); + assert.deepEqual(sessionWrites, [{ model: { provider: "other", id: b.id }, thinkingLevel: "xhigh" }]); + assert.equal(selected, `other:${b.id}`); assert.deepEqual(globals, { strong: body.slots.strong }); +}); + +test("availability and setup completeness never conflate missing metadata, disconnected providers or saved identities", () => { + const body = catalog(false); + assert.equal(savedSlotsConfigured(body.slots), true); + assert.equal(selectedModelUnavailable(body, `${a.provider}:${a.id}`), true); + assert.equal(selectedModelUnavailable(undefined, `${a.provider}:${a.id}`), false); + assert.equal(selectedModelUnavailable(body, undefined), false); + body.models = [b]; body.providers = [{ ...provider, keyPresent: true }]; + assert.equal(selectedModelUnavailable(body, `${b.provider}:${b.id}`), false); + body.slots.cheap = null; assert.equal(savedSlotsConfigured(body.slots), false); +}); + test("shared refresh fences late failure as well as late success, and Retry clears a current error", async () => { installFetch(); await warm(); await act(async () => snapshot.refresh()); await act(async () => snapshot.refresh()); From 8ad17a4fe4db2cd1dde9a22e0651c0035e4d7f53 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 27 Oct 2026 15:18:44 +0100 Subject: [PATCH 37/87] docs: fix relative links in development guide --- docs/architecture.md | 2 +- docs/development.md | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 627758c..6d1e28f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Learning Harness — Architecture -Status: **the canonical architecture spec** — describes the as-built system, verified against the code by a full audit on 2026-09-18 (every file under `src/`, `app/`, `components/`, `electron/`, `scripts/`, the lint config, and the test suite; `lint` and `typecheck` clean on the audited tree). The pre-audit spec is archived at [`archive/architecture-2026-09-08.md`](archive/architecture-2026-09-08.md) — history only. Product reasoning lives in [`docs/vision.md`](vision.md); file-by-file layout lives in the README. +Status: **the canonical architecture spec** — describes the as-built system, verified against the code by a full audit on 2026-09-18 (every file under `src/`, `app/`, `components/`, `electron/`, `scripts/`, the lint config, and the test suite; `lint` and `typecheck` clean on the audited tree). The pre-audit spec is archived at [`docs/archive/architecture-2026-09-08.md`](archive/architecture-2026-09-08.md) — history only. Product reasoning lives in [`docs/vision.md`](vision.md); file-by-file layout lives in the README. **What this is.** A standalone learning app (web + Electron desktop): the learner's knowledge accumulates into one persistent, cross-session knowledge graph, built from tutoring sessions in which a teacher agent authors a formal, visible dependency tree (the plan tree) and verifies understanding with structured questions. The graph is the home page; the session is the classroom; everything else serves those two. diff --git a/docs/development.md b/docs/development.md index 1859053..4c81a84 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,13 +2,13 @@ The developer README (split out of the storefront README, 2026-10-10). Everything below is for people working ON LearnHarness, not using it. -A standalone learning app (web + Electron desktop): a **teacher agent** (Next.js; the Vercel AI SDK's `UIMessage` protocol end-to-end, LLM engine [pi-ai](docs/pi-engine-swap.md) via OpenRouter), probe→teach lessons with graded quiz/ask interactions, a formal **session plan tree** the teacher authors once (`plan_init`) and whose progress is derived from tagged quiz calls, and a persistent cross-session **knowledge graph** of what the learner knows — the graph is the app's home page. +A standalone learning app (web + Electron desktop): a **teacher agent** (Next.js; the Vercel AI SDK's `UIMessage` protocol end-to-end, LLM engine [pi-ai](pi-engine-swap.md) via OpenRouter), probe→teach lessons with graded quiz/ask interactions, a formal **session plan tree** the teacher authors once (`plan_init`) and whose progress is derived from tagged quiz calls, and a persistent cross-session **knowledge graph** of what the learner knows — the graph is the app's home page. Not a chat app with a graph bolted on: sessions are probe→teach lessons, the plan tree is a structured, schema-validated object, sessions accumulate into the graph through the extraction pipeline (propositions + reasoning chains — the stored truth; the canvas renders a derived image), and the teacher reaches back into the graph through read-only prober tools on its own judgment so probing compounds across sessions. -- [`docs/architecture.md`](docs/architecture.md) is the **canonical architecture spec** — layers, the import law, the five systems, the invariants, the seam directory. (The pre-audit spec is archived under `docs/archive/` — history only.) -- Living design doc (all decisions): [`docs/vision.md`](docs/vision.md) -- Build plan + wave/gate results: [`docs/plan.md`](docs/plan.md) +- [`docs/architecture.md`](architecture.md) is the **canonical architecture spec** — layers, the import law, the five systems, the invariants, the seam directory. (The pre-audit spec is archived under `docs/archive/` — history only.) +- Living design doc (all decisions): [`docs/vision.md`](vision.md) +- Build plan + wave/gate results: [`docs/plan.md`](plan.md) ## Commands @@ -33,7 +33,7 @@ npx tsx --env-file=.env.local scripts/backfill-graph.ts # wipe + re-extrac npx tsx --env-file=.env.local scripts/replay-layout.ts # re-derive layout from the ledgers ``` -For Windows/Linux packages and the three-OS CI/release flow, see [`desktop-release.md`](desktop-release.md). Native renderer binaries are traced per platform, so release builds run on the target OS, not as cross-builds from macOS. +For Windows/Linux packages and the three-OS CI/release flow, see [`docs/desktop-release.md`](desktop-release.md). Native renderer binaries are traced per platform, so release builds run on the target OS, not as cross-builds from macOS. ## Environment (`.env.local`) @@ -60,7 +60,7 @@ This remains a **trusted developer/shared-server mode**, not multi-user hosting. ## Architecture -Five layers, dependency arrows pointing inward only, machine-enforced as four ESLint zones (shell → barrels only; core and agent never import below themselves). Two composition roots, one writer per store. The full spec — layers, systems, runtime flows, invariants, seams: [`docs/architecture.md`](docs/architecture.md). +Five layers, dependency arrows pointing inward only, machine-enforced as four ESLint zones (shell → barrels only; core and agent never import below themselves). Two composition roots, one writer per store. The full spec — layers, systems, runtime flows, invariants, seams: [`docs/architecture.md`](architecture.md). ```mermaid flowchart TB @@ -190,7 +190,7 @@ All deterministic, no LLM, no network (engines are injected — pi-ai's faux pro ## Lineage -The teaching methodology comes from an Obsidian-based POC (its `knowledge graph design.md` was the original epistemic-model source, since amended — see the amendments table in `docs/vision.md`). The harness was originally built on the pi agent SDK, pivoted to standalone Next.js + the Vercel AI SDK, and later swapped its LLM engine back to pi-ai while keeping the AI SDK's `UIMessage` as the client protocol ([`docs/pi-engine-swap.md`](docs/pi-engine-swap.md)). +The teaching methodology comes from an Obsidian-based POC (its `knowledge graph design.md` was the original epistemic-model source, since amended — see the amendments table in `docs/vision.md`). The harness was originally built on the pi agent SDK, pivoted to standalone Next.js + the Vercel AI SDK, and later swapped its LLM engine back to pi-ai while keeping the AI SDK's `UIMessage` as the client protocol ([`docs/pi-engine-swap.md`](pi-engine-swap.md)). ## Data From 06714e599859ccc742197224b96a1371916da58f Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 16:21:54 +0200 Subject: [PATCH 38/87] Keep unresolved session identity separate from Strong fallback --- components/model-picker.tsx | 26 +++++++++++++---------- tests/model-catalog-setup.test.tsx | 33 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/components/model-picker.tsx b/components/model-picker.tsx index b618e73..0b8e46d 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -43,7 +43,9 @@ export function catalogLimits(catalog: Pick): Ma export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) { const { catalog, refresh } = useModelCatalog(); const [models, setModels] = useState(); - const [session, setSession] = useState(); + // A successful read with no settings is a legacy session; undefined + // means no session record is known yet, not permission to inherit Strong. + const [session, setSession] = useState<{ settings?: ChatSettings }>(); const [open, setOpen] = useState<"model" | "thinking" | null>(null); const [error, setError] = useState(null); const containerRef = useRef(null); @@ -58,8 +60,8 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod const readRecord = useCallback(() => { if (chatId) { fetch(`/api/chats/${chatId}`) - .then((r) => r.json() as Promise<{ settings?: ChatSettings }>) - .then((body) => setSession(body.settings)) + .then((r) => { if (!r.ok) throw new Error(`session read failed: ${r.status}`); return r.json() as Promise<{ settings?: ChatSettings }>; }) + .then((body) => setSession({ settings: body.settings })) .catch(() => { /* same read-only posture as a models-section failure */ }); @@ -83,15 +85,17 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod }); // Dual-bound truth: chat mode displays the session's own record, - // falling back to the strong slot for legacy unstamped sessions — - // EXACTLY what resolveConversationSpec runs. Draft mode is the strong - // slot itself. + // falling back to Strong only for a successfully read legacy unstamped + // session — EXACTLY what resolveConversationSpec runs. An unresolved or + // failed record read must not report a provisional global preference as + // this session's identity (including to the Send guard). Draft mode is + // the strong slot itself. const strong = models?.strong; const override: ChatSettings | undefined = chatId - ? { - model: session?.model ?? strong?.model, - thinkingLevel: session?.thinkingLevel ?? strong?.thinkingLevel, - } + ? session ? { + model: session.settings?.model ?? strong?.model, + thinkingLevel: session.settings?.thinkingLevel ?? strong?.thinkingLevel, + } : undefined : strong; const effectiveId = override?.model?.id; const effectiveProvider = override?.model?.provider; @@ -195,7 +199,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod return; } const stored = (await res.json().catch(() => null)) as { settings?: ChatSettings } | null; - setSession(stored?.settings ?? next); + setSession({ settings: stored?.settings ?? next }); setOpen(null); } catch { setError("Could not reach the server — try again."); diff --git a/tests/model-catalog-setup.test.tsx b/tests/model-catalog-setup.test.tsx index 4583863..a426ae2 100644 --- a/tests/model-catalog-setup.test.tsx +++ b/tests/model-catalog-setup.test.tsx @@ -233,6 +233,39 @@ test("blocked session recovery opens its ordinary picker, never changes Strong o assert.equal(selected, `other:${b.id}`); assert.deepEqual(globals, { strong: body.slots.strong }); }); +for (const result of ["override", "legacy", "http-failure", "network-failure"] as const) test(`known catalog never enforces provisional Strong on a pending session; session read ${result}`, async () => { + installFetch(); const available = { ...b, provider: "other" }; const body = catalog(); + body.models = [available]; body.providers = [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }]; + // Global Strong is unavailable, but this existing session owns a valid + // model. Resolve global settings first while keeping its record pending. + const globals: ModelsSettings = { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "xhigh" } }; + let resolveSession!: (response: Response) => void; + let rejectSession!: (reason: Error) => void; + const sessionRead = new Promise((resolve, reject) => { resolveSession = resolve; rejectSession = reject; }); + const requestsOnly = globalThis.fetch; + globalThis.fetch = async (input, init) => { + if (input === "/api/settings") return Response.json({ settings: { models: globals } }); + if (input === "/api/chats/fixture") return sessionRead; + return requestsOnly(input, init); + }; + await warm(body); const identities: Array = []; + await render(<> { identities.push(id); }} />); + assert.ok(snapshot.catalog, "catalog is already known, not the deferred unknown-catalog case"); + assert.equal(element("stats-model").textContent, "—▾"); + assert.equal(identities.at(-1), undefined); + assert.equal(selectedModelUnavailable(snapshot.catalog, identities.at(-1)), false, "pending session cannot block Send using global Strong"); + await act(async () => { + if (result === "network-failure") rejectSession(new Error("fixture connection failure")); + else if (result === "http-failure") resolveSession(Response.json({ error: "fixture unavailable" }, { status: 503 })); + else resolveSession(Response.json(result === "legacy" ? {} : { settings: { model: { provider: available.provider, id: available.id }, thinkingLevel: "medium" } })); + }); + const expected = result === "legacy" ? `${a.provider}:${a.id}` : result === "override" ? `other:${b.id}` : undefined; + assert.equal(identities.at(-1), expected); + assert.equal(selectedModelUnavailable(snapshot.catalog, identities.at(-1)), result === "legacy", "only a confirmed legacy record may inherit the unavailable Strong pin"); + assert.equal(document.querySelector('[data-testid="model-menu"]'), null); + assert.deepEqual(writes, []); +}); + test("availability and setup completeness never conflate missing metadata, disconnected providers or saved identities", () => { const body = catalog(false); assert.equal(savedSlotsConfigured(body.slots), true); From 82c6999a635e56b934bb7901131688919c957dcc Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 16:49:29 +0200 Subject: [PATCH 39/87] fix: preserve failed responses when retrying model calls Keep failed attempts in the saved transcript and omit only trailing failures from Retry's temporary agent history. Use the matching temporary prefix length when persisting genuinely new output. Preserve the error notice across plan/compaction markers, refuse Retry after later compaction, and avoid synthesizing results for tool calls inside failed generations while retaining Pi's native filtering. Verified: 173 focused Retry/session/compaction-projection/practice-record tests, typecheck, changed-file lint, and diff --check. No app launch or real provider calls. --- components/chat.tsx | 13 +- components/session-core.ts | 11 +- src/core/input-events.ts | 67 ++-- src/core/native-messages.ts | 14 +- src/server/chat-handler.ts | 53 +-- tests/retry-failed-turn.test.ts | 566 +++++++++++++++++++++++++------- tests/session-core.test.ts | 35 +- 7 files changed, 559 insertions(+), 200 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index af6e585..203a09e 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -460,13 +460,6 @@ export function Chat({ const busy = localStream || turnInFlight; const busyRef = useRef(busy); busyRef.current = busy; - // A failed model call (issue #110): the banner offers Retry, which - // re-runs the turn from the saved input — the only path back, since an - // answered question cannot be answered again. Offered from "error" too: - // a dropped stream leaves the model there, and send() recovers from it. - const retryable = (status === "ready" || status === "error") && !busy && endsInFailedTurn(nativeMessages); - const retryFailedTurn = () => void send({ type: "retry" }); - // The attach handoff (stream-reattach wave): the moment a resumed // stream takes over the rendering — the SDK left "ready", so // submitted/streaming means chunks are flowing — localStream covers @@ -689,6 +682,12 @@ export function Chat({ // server's registry mutex is the structural gate; this is the // affordance). const [compacting, setCompacting] = useState(initiallyCompacting); + // A failed model response (issue #110): Retry keeps the saved input + // and completed work, without another learner message. Offered from + // "error" too: a dropped stream leaves the model there, and send() + // recovers from it. Later compaction requires new input instead. + const retryable = (status === "ready" || status === "error") && !busy && !compacting && endsInFailedTurn(nativeMessages); + const retryFailedTurn = () => void send({ type: "retry" }); const canSend = (text.trim().length > 0 || dictation.active) && !pending && !activeProblem && !compacting; diff --git a/components/session-core.ts b/components/session-core.ts index 0ac5d3f..c0ef90f 100644 --- a/components/session-core.ts +++ b/components/session-core.ts @@ -19,6 +19,7 @@ import { needsContinuation, danglingQuestionOf, answeredCallIds, + failedResponseAtEnd, measuredContextTokens, newAnswerResult, newNextMessage, @@ -286,12 +287,12 @@ export class SessionModel { ? { executions: this.executions, inFlight: true, streamingIds: this.streamingIds } : undefined; const messages = [...this.base, ...this.tail]; - const latest = messages.at(-1); - // One rule for live and restored failures: only the final completed - // assistant can own the banner. While trying again, hide the old - // failure from the first Thinking frame, but show a new failure at end. + const latest = failedResponseAtEnd(messages); + // Presentation/compaction markers do not resolve a failure. While + // trying again, hide the old banner from the first Thinking frame, + // but show a new completed failure from this consume immediately. const providerError = - latest?.role === "assistant" && latest.stopReason === "error" && + latest !== undefined && !this.incompleteUsageIds.has(latest.id) && (!live || latest.id === this.liveFailureId) ? { message: latest.errorMessage ?? "An error occurred." } : null; diff --git a/src/core/input-events.ts b/src/core/input-events.ts index 24f368a..714d7fb 100644 --- a/src/core/input-events.ts +++ b/src/core/input-events.ts @@ -16,9 +16,9 @@ * continue — run the next turn on the current record (mount * continuations, job-settle re-syncs: the record ends at an * answered question with no teacher reaction yet). - * retry — re-run a turn whose model call failed (issue #110): the - * trailing failed assistant message(s) leave the record and - * the turn re-runs from the input that preceded them. + * retry — retry a failed model response (issue #110), without new + * input. Failed attempts stay in the record; only the retry's + * temporary agent history omits the trailing failures. * * (The plan verdict input died 2026-09-26 with the verdict itself — the * learner's post-plan reply is a natural text message.) @@ -218,8 +218,8 @@ export function inputAppends( case "continue": return dismissalConclusion(record); case "retry": - // The failed turn's own input already landed write-ahead; the - // handler trims the failure (retryDrops) — nothing new appends. + // The input already landed write-ahead. retryDrops affects only + // the temporary agent history — nothing new appends here. return []; case "thinking": { const active = activeProblemOf(record.messages); @@ -244,40 +244,57 @@ export function inputAppends( } } -/** Does the record end in a failed model call (a retryable turn)? */ -export function endsInFailedTurn(messages: readonly LhMessage[]): boolean { - return failedTail(messages).ids.size > 0; +/** The failure owning the ephemeral banner. Plan presentation and + * compaction do not resolve an error; substantive later input/output does. + * This is deliberately broader than Retry eligibility below. */ +export function failedResponseAtEnd(messages: readonly LhMessage[]): Extract | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]!; + if (m.role === "custom" && (m.customType === "plan-block" || m.customType === "compaction")) continue; + return m.role === "assistant" && m.stopReason === "error" ? m : undefined; + } + return undefined; } -/** The ids a retry drops (issue #110, pure): the failed assistant - * message(s) ending the record. A provider failure persists as an - * assistant message with stopReason "error", and pi refuses to continue - * from an assistant — so neither re-answering (the question is no longer - * dangling) nor `continue` can resume the turn. Ids, not a cut index: - * UI-only markers may follow the failure, and they stay. Honest 400s - * when there is no failure to retry, or when dropping it leaves nothing - * to continue from. */ +/** Does the record end in a failure with input that can be retried? + * Aborts are deliberate stops; compaction after a failure requires new + * learner input rather than a retry using a changed context. */ +export function endsInFailedTurn(messages: readonly LhMessage[]): boolean { + const { ids, before } = failedTail(messages); + return ids.size > 0 && before !== undefined && before.role !== "assistant"; +} + +/** The ids omitted ONLY from the retry's temporary agent history. + * Failed attempts (including partial text and their ids) stay on disk. + * Pi's provider conversion already skips error/aborted responses, but + * continue() rejects an assistant tail BEFORE conversion. Omitting the + * trailing failures lets that continuation start from the saved input or + * completed tool results without replaying tools or resubmitting input. + * Plan blocks may follow the failure and stay; later compaction blocks + * Retry without changing compaction itself. */ export function retryDrops(messages: readonly LhMessage[]): Set { const { ids, before } = failedTail(messages); - if (ids.size === 0) throw new ChatInputError("retry input requires a failed turn to retry"); + if (ids.size === 0) { + const failure = failedResponseAtEnd(messages); + if (failure) { + throw new ChatInputError(`${failure.errorMessage ?? "An error occurred."}\n\nThis response cannot be retried after compaction. Send a new message to continue.`); + } + throw new ChatInputError("retry input requires a failed turn to retry"); + } if (!before || before.role === "assistant") throw new ChatInputError("retry input leaves no input to continue from"); return ids; } -/** Markers that can land AFTER a failed call and are never model input: - * persistTurn's plan block (plan_init succeeded, the next call failed) - * and the settle kick's compaction marker. */ -const TRAILING_MARKERS: readonly string[] = ["plan-block", "compaction"]; - -/** Walk back from the end over failed assistants and trailing markers: - * the failures' ids, and the message the run stops at. */ +/** Walk back over failed assistants and UI-only plan blocks. A compaction + * is a boundary, not a marker Retry may cross. A failure AFTER a compaction + * can still retry: that compacted context was already its starting state. */ function failedTail(messages: readonly LhMessage[]): { ids: Set; before: LhMessage | undefined } { const ids = new Set(); let i = messages.length - 1; for (; i >= 0; i--) { const m = messages[i]!; if (m.role === "assistant" && m.stopReason === "error") ids.add(m.id); - else if (!(m.role === "custom" && TRAILING_MARKERS.includes(m.customType))) break; + else if (!(m.role === "custom" && m.customType === "plan-block")) break; } return { ids, before: messages[i] }; } diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index 63f2720..871e462 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -551,9 +551,10 @@ function resultIdsFrom(messages: readonly AgentMessage[]): Set { * - markers flatten to a user message carrying their content (the * canonical text single-sourced in core/contracts); plan blocks are * UI-only and filtered. - * - dangling tool calls (an aborted turn's unanswered questions) get the - * synthesized interrupted result — the provider protocol's hard - * invariant: a tool call must be followed by its result. + * - dangling calls from successfully generated assistant messages (for + * example, an unanswered question) get a synthesized interrupted result. + * Error/aborted generations do not: Pi drops those assistant messages, + * so manufacturing their results would leave orphan results on the wire. * * Contract (pi-agent-core): never throws — an unknown shape passes * through untouched rather than killing the loop. @@ -619,11 +620,12 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] { // Strip the record id: the wire carries the native shape only. const { id: _id, ...native } = m as LhMessage; out.push(native as Message); - // Dangling-call synthesis: an assistant message whose toolCalls have - // no results in the remaining tail (abort mid-question) — the - // provider rejects unanswered calls outright. + // Repair unanswered calls from completed generations, not calls in + // failed generations. Pi filters error/aborted assistants itself; + // adding results for their unexecuted calls would leave orphans. if (role === "assistant") { const assistant = m as AssistantMessage; + if (assistant.stopReason === "error" || assistant.stopReason === "aborted") continue; const calls = assistant.content.filter((b) => b.type === "toolCall"); if (calls.length === 0) continue; const answered = resultIdsFrom(messages.slice(i + 1)); diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index 2c55f8e..b0eef2a 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -187,6 +187,17 @@ export async function handleChatInput( return Response.json({ error: "Unknown chat" }, { status: 404 }); } + // Refuse an ineligible Retry before catalog refresh, compaction, or any + // turn/write effects. Revalidate after the awaited gates below too. + if (input.type === "retry") { + try { + retryDrops(record.messages); + } catch (err) { + if (err instanceof ChatInputError) return Response.json({ error: err.message }, { status: 400 }); + throw err; + } + } + // THE UNIVERSAL TURN GATE (BYOK, owner, 2026-09-22): a turn needs the // CONVERSATION chain — the chat's own model override over the strong // slot — and there is no factory floor; when both are empty the turn @@ -224,18 +235,6 @@ export async function handleChatInput( return Response.json(envelope, { status: 503 }); } - // THE RETRY GATE (issue #110): a retry with no failure to retry - // refuses here, BEFORE compaction and beginTurn — no effects. The - // trim itself re-validates on the record compaction re-reads below. - if (input.type === "retry") { - try { - retryDrops(record.messages); - } catch (err) { - if (err instanceof ChatInputError) return Response.json({ error: err.message }, { status: 400 }); - throw err; - } - } - // THE COMPACTION GATE (owner round 14, 2026-10-05 — the structural // ruling: NOTHING model-facing for this chat interleaves with a // running compaction): await any in-flight compaction (the settle @@ -274,18 +273,17 @@ export async function handleChatInput( const compactedRecord = stores.chatStore.getChat(chatId); if (!compactedRecord) return Response.json({ error: "Unknown chat" }, { status: 404 }); record = compactedRecord; - // THE RETRY TRIM: recomputed on the current record (the awaits above - // let other writers land), still before beginTurn. The trimmed record - // reaches disk through the write-ahead. + // Recompute Retry eligibility on current truth, still before beginTurn. + // A compaction marker or another writer may have landed during awaits. + // These omissions affect ONLY the agent context, never the saved record. + let retryOmissions = new Set(); if (input.type === "retry") { - let drop: Set; try { - drop = retryDrops(record.messages); + retryOmissions = retryDrops(record.messages); } catch (err) { if (err instanceof ChatInputError) return Response.json({ error: err.message }, { status: 400 }); throw err; } - record = { ...record, messages: record.messages.filter((m) => !drop.has(m.id)) }; } // The input's native appends (pure; the honest 400 for an answer with no @@ -344,8 +342,13 @@ export async function handleChatInput( // ride this same write-ahead - the pump de-queued them at fire, so this // is the ONLY copy ever appended.) const merged = [...record.messages, ...appends]; + // Retry preserves every persisted attempt. Pi needs a non-assistant + // tail to start continue(), so its temporary history omits the trailing + // failures only. Count that SAME temporary prefix at settlement, or the + // retained failures would make persistTurn skip genuinely new output. + const agentMessages = input.type === "retry" ? merged.filter((m) => !retryOmissions.has(m.id)) : merged; const stagedAutomatic = isSubscriptionProvider(conversation.provider) && input.type === "steers"; - let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length; + let persistBaseCount = stagedAutomatic ? record.messages.length : agentMessages.length; try { if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged); } catch (err) { @@ -384,7 +387,7 @@ export async function handleChatInput( // the session is still probing: build the PROBER (quiz/ask + probe_done). // Both share this whole composition (write-ahead persist, liveness, // steers, usage, extraction) — only the agent differs. - const teaching = hasProbeDone(merged); + const teaching = hasProbeDone(agentMessages); // BYOK (owner, 2026-09-22): the conversation chain was resolved by the // turn gate above (chat.model override → strong slot → refused) — the // engine build is pure from here; thinking levels clamp natively. @@ -403,7 +406,7 @@ export async function handleChatInput( // sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the // user: quiz/ask/fs/plan/probe_done are always-on). if (isSubscriptionProvider(conversation.provider)) requestCycle = new SubscriptionRequestCycle({ - initialMessages: asAgentMessages(merged), + initialMessages: asAgentMessages(agentMessages), staged: stagedAutomatic ? asAgentMessages(appends) : undefined, foregroundOwed: !callPurpose.background, purpose: callPurpose, @@ -419,7 +422,7 @@ export async function handleChatInput( engine, callPurpose, requestCycle, - messages: asAgentMessages(merged), + messages: asAgentMessages(agentMessages), onUsage: collectUsage, toolToggles: effective.agent.tools, // Debug (owner, 2026-10-04): teacher/prober raw provider events under @@ -515,7 +518,7 @@ export async function handleChatInput( // Debug recorder run marker (owner, 2026-10-04): correlates the // JSONL event stream with the record's turns; no-op unless // LH_PROVIDER_EVENTS=1. - providerEventNote(chatId, "turn", `run start (base: ${merged.length} messages)`); + providerEventNote(chatId, "turn", `run start (base: ${agentMessages.length} messages)`); const handle = runPiTurn({ agent: constructed, requestCycle, @@ -555,8 +558,8 @@ export async function handleChatInput( settleSteers(chatId, persisted, true, constructed); }, onFinish: (turnMessages, isAborted) => { - // Wave B: the record write is the agent's own final state — - // the new messages are the slice past the merged base. + // Wave B: append only messages past the temporary agent prefix. + // Retry's saved record still includes its earlier failed attempts. const stamped = persistTurn(stores.chatStore, chatId, persistBaseCount, turnMessages, isAborted); noteSteersDurable(chatId, stores.chatStore.getChat(chatId)?.messages ?? stamped); // usage log append (idempotent per turn id) — the stats bar diff --git a/tests/retry-failed-turn.test.ts b/tests/retry-failed-turn.test.ts index dbd7507..38ba054 100644 --- a/tests/retry-failed-turn.test.ts +++ b/tests/retry-failed-turn.test.ts @@ -1,32 +1,47 @@ /** - * The failed-turn retry (issue #110): a model call that fails persists as an - * assistant message with stopReason "error". The learner's input survives - * (write-ahead), but pre-fix nothing could re-run the turn — re-answering - * 400'd (the question is no longer dangling) and `continue` died in pi - * ("Cannot continue from message role: assistant"). The `retry` input drops - * the failure and re-runs the turn from the input that preceded it. - * - * Drives handleChatRequest with a scripted pi engine (the chat-flow pattern). + * Failed-response Retry (issue #110). Input is already durable. Retry + * omits the trailing failures from the temporary agent context, never + * deletes them from the transcript, and appends only genuinely new output. + * Scripted faux engines: no real provider calls or learning data. */ import assert from "node:assert/strict"; import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; -import { after, describe, it } from "node:test"; +import { after, describe, it, mock } from "node:test"; import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai"; -import { SessionModel } from "../components/session-core.ts"; -import { createTeacherAgent } from "../src/agent/teacher-agent.ts"; -import { handleChatRequest } from "../src/server/chat-handler.ts"; -import { runningTurn } from "../src/server/turn-runs.ts"; -import { chatStore } from "../src/store/chats.ts"; -import { buildAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts"; -import { ChatInputError, endsInFailedTurn, parseChatInput, retryDrops } from "../src/core/input-events.ts"; -import { newPlanBlockMessage, newUserMessage, type LhMessage } from "../src/core/native-messages.ts"; -import { newCompactionMarker } from "../src/core/compaction.ts"; +import { transformMessages } from "@earendil-works/pi-ai/api/transform-messages"; +import { Type } from "typebox"; +import type { AgentBuildOptions, TeachingAgent } from "../src/agent/teacher-agent.ts"; import type { PiEngine } from "../src/agent/models.ts"; -import { PATHS } from "../config.ts"; +import type { LhMessage } from "../src/core/native-messages.ts"; +import type { QuestionData } from "../src/core/question-core.ts"; -process.env.OPENROUTER_API_KEY ??= "test-key-presence-gate"; +// Set the storage/auth seams BEFORE any app/config-loading imports. +assert.equal(process.env.NODE_ENV, "test", "Run with NODE_ENV=test; these tests must not invoke real providers."); +const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-retry-failed-turn-")); +process.env.LH_DATA_DIR = path.join(scratch, "data"); +process.env.LH_AUTH_FILE = path.join(scratch, "auth.json"); +process.env.OPENROUTER_API_KEY = "test-key-presence-gate"; +fs.writeFileSync(process.env.LH_AUTH_FILE, "{}"); + +const { SessionModel } = await import("../components/session-core.ts"); +const { createPracticeAgent, createProberAgent, createTeacherAgent } = await import("../src/agent/teacher-agent.ts"); +const { handleChatRequest } = await import("../src/server/chat-handler.ts"); +const { clearTurnFailure, rememberTurnFailure, runningTurn, turnFailure } = await import("../src/server/turn-runs.ts"); +const { isTurnRunning } = await import("../src/server/turn-liveness.ts"); +const { chatStore } = await import("../src/store/chats.ts"); +const { jobStore } = await import("../src/store/jobs.ts"); +const { subscribe } = await import("../src/core/events.ts"); +const { buildAnswer, normalizeOptions, resolveCorrect } = await import("../src/core/question-core.ts"); +const { ChatInputError, endsInFailedTurn, failedResponseAtEnd, inputAppends, parseChatInput, retryDrops } = await import("../src/core/input-events.ts"); +const { INTERRUPTED_TEXT, asAgentMessages, newHandoffMessage, newPlanBlockMessage, newUserMessage, reconstructRequestCause, toLlmMessages } = await import("../src/core/native-messages.ts"); +const { newCompactionMarker } = await import("../src/core/compaction.ts"); + +after(() => { + fs.rmSync(scratch, { recursive: true, force: true }); +}); const QUIZ_INPUT = { question: "Which planet is closest to the sun?", @@ -37,23 +52,20 @@ const QUIZ_INPUT = { nodeId: "planet-order", }; -/** A response step that fails the way a dropped connection does. */ -const networkFailure = (() => { - throw new Error("fetch failed"); -}) as unknown as FauxResponseStep; +const networkFailure: FauxResponseStep = () => { throw new Error("fetch failed"); }; -/** Scripted engine; each step records the conversation roles it saw - * (the composed system message aside). */ -function scripted(steps: FauxResponseStep[]): { engine: PiEngine; contexts: string[][] } { +/** Record the actual model context after the app's native conversion. */ +function scripted(steps: FauxResponseStep[]) { const contexts: string[][] = []; + const histories: string[] = []; const faux = createFauxCore({}); - faux.setResponses( - steps.map((step) => ((context: { messages: Array<{ role: string }> }, ...rest: unknown[]) => { - contexts.push(context.messages.map((m) => m.role).filter((r) => r !== "system")); - return (step as (...a: unknown[]) => unknown)(context, ...rest); - }) as unknown as FauxResponseStep), - ); - return { engine: { model: faux.getModel(), streamFn: faux.streamSimple }, contexts }; + faux.setResponses(steps.map((step) => (context, options, state, model) => { + contexts.push(context.messages.map((m) => m.role).filter((role) => role !== "system")); + histories.push(JSON.stringify(context.messages)); + return typeof step === "function" ? step(context, options, state, model) : step; + })); + const engine: PiEngine = { model: faux.getModel(), streamFn: faux.streamSimple }; + return { engine, contexts, histories }; } function post(chatId: string, input: unknown): Request { @@ -64,10 +76,13 @@ function post(chatId: string, input: unknown): Request { }); } -/** POST one input event; on a 200 ack, drain the turn to completion. */ -async function turn(engine: PiEngine, chatId: string, input: unknown): Promise { - const ack = await handleChatRequest(post(chatId, input), (opts) => - createTeacherAgent(undefined, { engine, messages: opts.messages }), +type AgentFactory = (opts: AgentBuildOptions & { teaching: boolean }) => TeachingAgent; + +/** POST one event; after acceptance drain the server-owned turn. */ +async function turn(engine: PiEngine, chatId: string, input: unknown, buildAgent?: AgentFactory): Promise { + const ack = await handleChatRequest( + post(chatId, input), + buildAgent ?? ((opts) => createTeacherAgent(undefined, { ...opts, engine })), ); if (ack.status === 200) { const handle = runningTurn(chatId); @@ -76,20 +91,8 @@ async function turn(engine: PiEngine, chatId: string, input: unknown): Promise { - for (const id of createdChats) { - try { - fs.unlinkSync(path.join(PATHS.chats, `${id}.json`)); - } catch { - /* already gone */ - } - } -}); - -function createConfiguredChat(title: string) { - const chat = chatStore.createChat(title); - createdChats.push(chat.id); +function createConfiguredChat(title: string, kind?: "practice") { + const chat = chatStore.createChat(title, kind); chatStore.saveSettings(chat.id, { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" } }); chatStore.saveTree(chat.id, { nodes: [{ id: "planet-order", title: "Planet order" }], edges: [], version: 1 }); return chat; @@ -107,134 +110,445 @@ function gradedAnswer() { explanation: QUIZ_INPUT.explanation, createdAt: new Date().toISOString(), }; - return buildAnswer(qd, { values: ["Mercury"] }); + return buildAnswer(qd, { values: ["Mercury"], thinking: "Mercury is the innermost", readingThinking: "I followed the planet ordering", confidence: 80 }); } const roles = (chatId: string) => chatStore.getChat(chatId)!.messages.map((m) => m.role); +const failed = (id: string, text = ""): LhMessage => ({ + ...fauxAssistantMessage(text, { stopReason: "error", errorMessage: "fetch failed" }), id, +}); +const compaction = (firstKeptMessageId: string) => newCompactionMarker("summary", { + firstKeptMessageId, tokensBefore: 100, model: "fixture", at: "2026-01-01T00:00:00Z", +}); -describe("failed-turn retry (issue #110)", () => { - it("a failed answer turn re-runs from the saved answer — no re-answering needed", async () => { - const { engine, contexts } = scripted([ +function assertPreserved(before: LhMessage[], saved: LhMessage[]): void { + assert.deepEqual(saved.slice(0, before.length), before, "all previous messages, ids and partial content stay intact"); +} + +describe("failed-response Retry (issue #110)", () => { + it("retries from the saved answer, preserving the full input and failed attempt", async () => { + const { engine, contexts, histories } = scripted([ () => fauxAssistantMessage([fauxToolCall("quiz", QUIZ_INPUT, { id: "call-1" })], { stopReason: "toolUse" }), networkFailure, () => fauxAssistantMessage([fauxText("Right — Mercury it is.")]), ]); const chat = createConfiguredChat("Test: retry after failed answer"); + const answer = gradedAnswer(); await turn(engine, chat.id, { type: "text", text: "teach me the planets" }); - await turn(engine, chat.id, { type: "answer", toolCallId: "call-1", answer: gradedAnswer() }); - - // The failure as #110 met it: answer durable, turn failed, and the - // pre-fix paths refused. + await turn(engine, chat.id, { type: "answer", toolCallId: "call-1", answer }); + const before = chatStore.getChat(chat.id)!.messages; assert.deepEqual(roles(chat.id), ["user", "assistant", "toolResult", "assistant"]); - assert.ok(endsInFailedTurn(chatStore.getChat(chat.id)!.messages)); - const reanswer = await turn(engine, chat.id, { type: "answer", toolCallId: "call-1", answer: gradedAnswer() }); - assert.equal(reanswer.status, 400, "the answered question cannot be answered again"); - - const retry = await turn(engine, chat.id, { type: "retry" }); - assert.equal(retry.status, 200); + assert.ok(endsInFailedTurn(before)); + assert.equal((await turn(engine, chat.id, { type: "answer", toolCallId: "call-1", answer })).status, 400); + assert.equal((await turn(engine, chat.id, { type: "retry" })).status, 200); const saved = chatStore.getChat(chat.id)!.messages; - assert.deepEqual(saved.map((m) => m.role), ["user", "assistant", "toolResult", "assistant"]); - assert.equal(endsInFailedTurn(saved), false, "the failure left the record"); + assertPreserved(before, saved); + assert.deepEqual(roles(chat.id), ["user", "assistant", "toolResult", "assistant", "assistant"]); + assert.equal(endsInFailedTurn(saved), false, "a new successful response supersedes the retained failure"); + const results = saved.filter((m) => m.role === "toolResult" && m.toolCallId === "call-1"); + assert.equal(results.length, 1, "the learner's answer was not submitted twice"); + const result = results[0]!; + assert.ok(result.role === "toolResult"); + assert.deepEqual(result.details, JSON.parse(JSON.stringify(answer))); assert.match(JSON.stringify(saved.at(-1)), /Mercury it is/); - assert.deepEqual(contexts.at(-1), ["user", "assistant", "toolResult"], "the retried call saw the answer, not the failure"); + assert.deepEqual(contexts.at(-1), ["user", "assistant", "toolResult"]); + assert.match(histories.at(-1)!, /Mercury is the innermost/); }); - it("a failed text turn re-runs from the learner's message", async () => { - const { engine, contexts } = scripted([networkFailure, () => fauxAssistantMessage([fauxText("Hello again.")])]); + it("a failed text turn retries without adding another learner message", async () => { + const { engine, contexts } = scripted([networkFailure, () => fauxAssistantMessage("Hello again.")]); const chat = createConfiguredChat("Test: retry after failed text"); - await turn(engine, chat.id, { type: "text", text: "hello" }); - assert.deepEqual(roles(chat.id), ["user", "assistant"]); - assert.ok(endsInFailedTurn(chatStore.getChat(chat.id)!.messages)); - + const before = chatStore.getChat(chat.id)!.messages; + assert.ok(endsInFailedTurn(before)); assert.equal((await turn(engine, chat.id, { type: "retry" })).status, 200); - assert.deepEqual(roles(chat.id), ["user", "assistant"]); - assert.match(JSON.stringify(chatStore.getChat(chat.id)!.messages.at(-1)), /Hello again/); + const saved = chatStore.getChat(chat.id)!.messages; + assertPreserved(before, saved); + assert.deepEqual(roles(chat.id), ["user", "assistant", "assistant"]); + assert.deepEqual(contexts.at(-1), ["user"]); + assert.match(JSON.stringify(saved.at(-1)), /Hello again/); + }); + + it("preserves partial text and its usage reference before the replacement succeeds", { timeout: 5_000 }, async () => { + const chat = createConfiguredChat("Test: retained partial reply"); + const before = [newUserMessage("hello"), failed("partial", "A partial explanation already shown.")]; + chatStore.saveMessages(chat.id, before); + const usage = { id: "original-turn", messageId: "partial", at: "2026-01-01T00:00:00Z", model: "faux:fixture", steps: [] }; + chatStore.appendTurnUsage(chat.id, usage); + let release!: () => void; + let started!: () => void; + const waiting = new Promise((resolve) => { release = resolve; }); + const entered = new Promise((resolve) => { started = resolve; }); + const { engine, histories } = scripted([async () => { + started(); + await waiting; + return fauxAssistantMessage("Replacement response."); + }]); + const retry = turn(engine, chat.id, { type: "retry" }); + await entered; + try { + assert.deepEqual(chatStore.getChat(chat.id)!.messages, before, "write-ahead must not delete the failed attempt"); + assert.deepEqual(chatStore.getChat(chat.id)!.turns, [usage]); + assert.doesNotMatch(histories.at(-1)!, /partial explanation/); + } finally { + release(); + await retry; + } + const saved = chatStore.getChat(chat.id)!; + assertPreserved(before, saved.messages); + assert.ok(saved.turns?.some((entry) => entry.id === usage.id && entry.messageId === "partial")); + assert.equal(saved.messages.length, before.length + 1, "the persistence offset counts the temporary prefix, not the full saved record"); + }); + + it("keeps every failed attempt across repeated retries, then appends success once", async () => { + const { engine, contexts } = scripted([networkFailure, networkFailure, () => fauxAssistantMessage("Recovered.")]); + const chat = createConfiguredChat("Test: repeated model failure"); + await turn(engine, chat.id, { type: "text", text: "hello" }); + const first = chatStore.getChat(chat.id)!.messages; + await turn(engine, chat.id, { type: "retry" }); + const second = chatStore.getChat(chat.id)!.messages; + assertPreserved(first, second); + assert.equal(second.length, first.length + 1); + assert.ok(endsInFailedTurn(second)); + await turn(engine, chat.id, { type: "retry" }); + const saved = chatStore.getChat(chat.id)!.messages; + assertPreserved(second, saved); + assert.equal(saved.length, second.length + 1); + assert.deepEqual(contexts, [["user"], ["user"], ["user"]]); + assert.equal(new SessionModel({ chatId: chat.id, initialMessages: saved }).getState().error, null); + }); + + it("preserves a trailing plan block while retrying from the preceding input", async () => { + const { engine, contexts } = scripted([networkFailure, () => fauxAssistantMessage("Planned reply.")]); + const chat = createConfiguredChat("Test: retry past a plan block"); + await turn(engine, chat.id, { type: "text", text: "hello" }); + const before = [...chatStore.getChat(chat.id)!.messages, newPlanBlockMessage("plan-1", {})]; + chatStore.saveMessages(chat.id, before); + assert.ok(endsInFailedTurn(before)); + assert.equal((await turn(engine, chat.id, { type: "retry" })).status, 200); + const saved = chatStore.getChat(chat.id)!.messages; + assertPreserved(before, saved); + assert.deepEqual(roles(chat.id), ["user", "assistant", "custom", "assistant"]); + assert.deepEqual(contexts.at(-1), ["user"], "the plan block remains UI-only"); + }); + + it("does not reexecute completed tools before a later model failure", async () => { + let executions = 0; + const { engine, contexts } = scripted([ + () => fauxAssistantMessage([fauxToolCall("save_note", {}, { id: "note-1" })], { stopReason: "toolUse" }), + networkFailure, + () => fauxAssistantMessage("The saved note is ready."), + ]); + const tools: AgentBuildOptions["tools"] = { + save_note: { + name: "save_note", label: "save_note", description: "Record one fixture side effect.", parameters: Type.Object({}), + execute: async () => { + executions++; + return { content: [{ type: "text", text: "Note saved" }], details: { saved: true } }; + }, + }, + }; + const factory: AgentFactory = (opts) => createTeacherAgent(undefined, { ...opts, engine, tools }); + const chat = createConfiguredChat("Test: no completed tool replay"); + await turn(engine, chat.id, { type: "text", text: "save a note" }, factory); + assert.equal(executions, 1); + const before = chatStore.getChat(chat.id)!.messages; + assert.deepEqual(roles(chat.id), ["user", "assistant", "toolResult", "assistant"]); + assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200); + assert.equal(executions, 1, "Retry requests another response, not execution of stored calls"); + assertPreserved(before, chatStore.getChat(chat.id)!.messages); + assert.deepEqual(contexts.at(-1), ["user", "assistant", "toolResult"]); + }); + + it("never executes tool calls carried by a failed partial response", async () => { + let executions = 0; + const { engine, contexts } = scripted([ + () => fauxAssistantMessage([fauxToolCall("save_note", {}, { id: "unexecuted" })], { stopReason: "error", errorMessage: "cut off" }), + () => fauxAssistantMessage("Recovered without running the partial call."), + ]); + const factory: AgentFactory = (opts) => createTeacherAgent(undefined, { + ...opts, engine, + tools: { + save_note: { + name: "save_note", label: "save_note", description: "Must not execute.", parameters: Type.Object({}), + execute: async () => { executions++; return { content: [], details: {} }; }, + }, + }, + }); + const chat = createConfiguredChat("Test: unexecuted partial tool call"); + await turn(engine, chat.id, { type: "text", text: "hello" }, factory); + assert.equal(executions, 0); + const before = chatStore.getChat(chat.id)!.messages; + assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200); + assert.equal(executions, 0); + assertPreserved(before, chatStore.getChat(chat.id)!.messages); assert.deepEqual(contexts.at(-1), ["user"]); }); - it("refuses a retry with nothing failed — 400, record untouched", async () => { - const { engine } = scripted([() => fauxAssistantMessage([fauxText("Fine.")])]); - const chat = createConfiguredChat("Test: retry refused"); + for (const kind of ["prober", "teacher", "practice"] as const) { + it(`uses the same retained-history retry boundary for the ${kind}`, async () => { + const { engine, contexts } = scripted([() => fauxAssistantMessage("Recovered response.")]); + const chat = createConfiguredChat(`Test: ${kind} retry`, kind === "practice" ? "practice" : undefined); + const before: LhMessage[] = [newUserMessage("hello")]; + if (kind === "teacher") { + before.push({ ...fauxAssistantMessage([fauxToolCall("probe_done", {}, { id: "probe-1" })], { stopReason: "toolUse" }), id: "probe-response" }); + before.push({ id: "probe-result", role: "toolResult", toolCallId: "probe-1", toolName: "probe_done", content: [], details: { ok: true }, isError: false, timestamp: 1 }); + } + before.push(failed("failed-response", "Partial reply.")); + chatStore.saveMessages(chat.id, before); + const factory: AgentFactory = (opts) => { + assert.equal(opts.teaching, kind === "teacher"); + assert.ok(opts.messages?.every((m) => m.role !== "assistant" || m.stopReason !== "error")); + if (kind === "practice") return createPracticeAgent({ chatId: chat.id, chatStore }, { ...opts, engine }); + if (kind === "prober") return createProberAgent({ chatId: chat.id, chatStore, jobStore, deliver: () => {}, spawn: () => {} }, { ...opts, engine }); + return createTeacherAgent(undefined, { ...opts, engine }); + }; + assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200); + assertPreserved(before, chatStore.getChat(chat.id)!.messages); + assert.equal(chatStore.getChat(chat.id)!.messages.length, before.length + 1); + assert.deepEqual(contexts.at(-1), kind === "teacher" ? ["user", "assistant", "toolResult"] : ["user"]); + }); + } - await turn(engine, chat.id, { type: "text", text: "hello" }); - const before = chatStore.getChat(chat.id)!.messages; - const res = await turn(engine, chat.id, { type: "retry" }); - assert.equal(res.status, 400); - assert.match(await res.text(), /requires a failed turn/); - assert.deepEqual(chatStore.getChat(chat.id)!.messages, before); + it("treats an explicit Retry of an automatic main-agent response as foreground", async () => { + const { engine } = scripted([() => fauxAssistantMessage("Explicitly requested response.")]); + const chat = createConfiguredChat("Test: foreground manual retry"); + const before = [newUserMessage("hello"), newHandoffMessage(chat.id), failed("automatic-failure")]; + chatStore.saveMessages(chat.id, before); + assert.equal(reconstructRequestCause(before), "background"); + let built = false; + const factory: AgentFactory = (opts) => { + built = true; + assert.equal(opts.callPurpose?.background, false); + return createTeacherAgent(undefined, { ...opts, engine }); + }; + assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200); + assert.ok(built); + assertPreserved(before, chatStore.getChat(chat.id)!.messages); + }); +}); + +describe("retained generations and Pi's native provider filtering", () => { + for (const stopReason of ["error", "aborted"] as const) { + it(`does not fabricate results for ${stopReason} partial tool calls on later requests`, () => { + const faux = createFauxCore({}); + const partial: LhMessage = { + ...fauxAssistantMessage([fauxToolCall("save_note", {}, { id: "partial-call" })], { stopReason }), id: "partial", + }; + const recovered: LhMessage = { ...fauxAssistantMessage("Recovered."), id: "recovered" }; + const messages = [newUserMessage("hello"), partial, recovered, newUserMessage("next question")]; + const before = JSON.parse(JSON.stringify(messages)); + const converted = toLlmMessages(asAgentMessages(messages)); + assert.ok(converted.some((m) => m.role === "assistant" && m.stopReason === stopReason), "the app still leaves failed-generation filtering to Pi"); + assert.equal(converted.filter((m) => m.role === "toolResult").length, 0, "no orphan synthetic result for an unexecuted partial call"); + const providerHistory = transformMessages(converted, faux.getModel()); + assert.deepEqual(providerHistory.map((m) => m.role), ["user", "assistant", "user"]); + assert.doesNotMatch(JSON.stringify(providerHistory), /partial-call/); + assert.deepEqual(messages, before, "model conversion never changes the saved attempts"); + }); + } + + it("keeps an actual tool-cancellation result and its completed calling message", () => { + const faux = createFauxCore({}); + const call: LhMessage = { + ...fauxAssistantMessage([fauxToolCall("save_note", {}, { id: "completed-call" })], { stopReason: "toolUse" }), id: "caller", + }; + const cancellation: LhMessage = { + id: "cancelled-result", role: "toolResult", toolCallId: "completed-call", toolName: "save_note", + content: [{ type: "text", text: "Tool execution was aborted." }], isError: true, timestamp: 1, + }; + const messages = [newUserMessage("hello"), call, cancellation, newUserMessage("continue")]; + const providerHistory = transformMessages(toLlmMessages(asAgentMessages(messages)), faux.getModel()); + assert.deepEqual(providerHistory.map((m) => m.role), ["user", "assistant", "toolResult", "user"]); + const result = providerHistory.find((m) => m.role === "toolResult"); + assert.ok(result?.role === "toolResult"); + assert.equal(result.toolCallId, "completed-call"); + assert.equal(result.isError, true); + assert.deepEqual(result.content, cancellation.content); }); - it("a plan block persisted after the failure stays, and the turn still retries", async () => { - const { engine, contexts } = scripted([networkFailure, () => fauxAssistantMessage([fauxText("Planned reply.")])]); - const chat = createConfiguredChat("Test: retry past a plan block"); + it("still repairs an unanswered call from a completed generation", () => { + const faux = createFauxCore({}); + const question: LhMessage = { + ...fauxAssistantMessage([fauxToolCall("quiz", QUIZ_INPUT, { id: "unanswered-question" })], { stopReason: "toolUse" }), id: "question", + }; + const messages = [newUserMessage("hello"), question, newUserMessage("move on")]; + const before = JSON.parse(JSON.stringify(messages)); + const providerHistory = transformMessages(toLlmMessages(asAgentMessages(messages)), faux.getModel()); + assert.deepEqual(providerHistory.map((m) => m.role), ["user", "assistant", "toolResult", "user"]); + const result = providerHistory.find((m) => m.role === "toolResult"); + assert.ok(result?.role === "toolResult"); + assert.equal(result.toolCallId, "unanswered-question"); + assert.deepEqual(result.content, [{ type: "text", text: INTERRUPTED_TEXT }]); + assert.deepEqual(messages, before, "the repair is temporary, never persisted"); + }); +}); +describe("Retry refusal and revalidation", () => { + it("rejects an invalid Retry before provider gates, turn claims or writes", async () => { + const chat = chatStore.createChat("Test: retry refused before subscription refresh"); + chatStore.saveSettings(chat.id, { model: { provider: "chatgpt", id: "not-a-fixture-model" } }); + const before = chatStore.getChat(chat.id)!; + rememberTurnFailure(chat.id, "Existing settlement notice"); + const events: string[] = []; + const unsubscribe = subscribe((event) => { if ("chatId" in event && event.chatId === chat.id) events.push(event.type); }); + try { + const res = await handleChatRequest(post(chat.id, { type: "retry" }), () => { throw new Error("must not construct an agent"); }); + assert.equal(res.status, 400, "invalid retry wins even with an unavailable subscription model"); + assert.match(await res.text(), /requires a failed turn/); + assert.deepEqual(chatStore.getChat(chat.id), before); + assert.equal(turnFailure(chat.id), "Existing settlement notice", "beginTurn did not clear recovery state"); + assert.equal(isTurnRunning(chat.id), false); + assert.deepEqual(events, []); + } finally { + unsubscribe(); + clearTurnFailure(chat.id); + } + }); + + it("rejects Retry after later compaction without removing the error or its boundary", async () => { + const { engine, contexts } = scripted([]); + const chat = createConfiguredChat("Test: compacted failure"); + const failure = failed("partial", "Partial explanation."); + const marker = compaction(failure.id); + const before = [newUserMessage("hello"), failure, marker]; + chatStore.saveMessages(chat.id, before); + const snapshot = chatStore.getChat(chat.id)!; + const res = await turn(engine, chat.id, { type: "retry" }); + assert.equal(res.status, 400); + const body = await res.json() as { error: string }; + assert.equal(body.error, "fetch failed\n\nThis response cannot be retried after compaction. Send a new message to continue."); + assert.deepEqual(chatStore.getChat(chat.id), snapshot); + assert.equal(contexts.length, 0); + assert.equal(new SessionModel({ chatId: chat.id, initialMessages: before }).getState().error?.message, "fetch failed"); + assert.equal(endsInFailedTurn(before), false); + }); + + for (const later of ["compaction", "learner-input"] as const) { + it(`revalidates against ${later} landing on the post-compaction reread`, async () => { + const { engine, contexts } = scripted([]); + const chat = createConfiguredChat(`Test: retry race with ${later}`); + const before = [newUserMessage("hello"), failed("failed-response")]; + chatStore.saveMessages(chat.id, before); + const appended = later === "compaction" ? compaction("failed-response") : newUserMessage("A newer input"); + const read = chatStore.getChat; + let reads = 0; + // Simulate another writer landing during the awaited gate. Do not + // change or invoke the compaction implementation to exercise Retry. + const reader = mock.method(chatStore, "getChat", (id: string) => { + if (id === chat.id && ++reads === 2) chatStore.saveMessages(id, [...before, appended]); + return read(id); + }); + const events: string[] = []; + const unsubscribe = subscribe((event) => { if ("chatId" in event && event.chatId === chat.id) events.push(event.type); }); + try { + const res = await turn(engine, chat.id, { type: "retry" }); + assert.equal(res.status, 400); + if (later === "compaction") assert.match(await res.text(), /fetch failed.*cannot be retried after compaction/); + assert.deepEqual(read(chat.id)!.messages, [...before, appended]); + assert.equal(contexts.length, 0); + assert.equal(isTurnRunning(chat.id), false); + assert.deepEqual(events, [], "no turn was claimed after final validation refused"); + } finally { + reader.mock.restore(); + unsubscribe(); + } + }); + } + + it("a sibling's successful retry makes a later stale Retry refuse without changing history", async () => { + const { engine, contexts } = scripted([networkFailure, () => fauxAssistantMessage("Recovered.")]); + const chat = createConfiguredChat("Test: stale sibling Retry"); await turn(engine, chat.id, { type: "text", text: "hello" }); - // persistTurn's shape when plan_init succeeded and the next call - // failed: the plan block lands AFTER the failure. - const failedRecord = chatStore.getChat(chat.id)!.messages; - chatStore.saveMessages(chat.id, [...failedRecord, newPlanBlockMessage("plan-1", {})]); - assert.ok(endsInFailedTurn(chatStore.getChat(chat.id)!.messages), "the failure is still found past the marker"); - - assert.equal((await turn(engine, chat.id, { type: "retry" })).status, 200); - const saved = chatStore.getChat(chat.id)!.messages; - assert.deepEqual(saved.map((m) => m.role), ["user", "custom", "assistant"], "failure dropped, plan block kept"); - assert.match(JSON.stringify(saved.at(-1)), /Planned reply/); - assert.deepEqual(contexts.at(-1), ["user"], "the plan block is not model input"); + await turn(engine, chat.id, { type: "retry" }); + const before = chatStore.getChat(chat.id)!; + assert.equal((await turn(engine, chat.id, { type: "retry" })).status, 400); + assert.deepEqual(chatStore.getChat(chat.id), before); + assert.equal(contexts.length, 2); }); }); describe("the Retry button's client path", () => { + it("keeps the original model error plus the explanation when compaction wins the click race", async () => { + const messages = [newUserMessage("hello"), failed("failed-response")]; + const compacted = [...messages, compaction("failed-response")]; + const explanation = "This response cannot be retried after compaction. Send a new message to continue."; + const refused = `fetch failed\n\n${explanation}`; + const m = new SessionModel({ + chatId: "chat_x", initialMessages: messages, + fetch: async (_url, init) => { + assert.equal(init?.method, "POST"); + assert.deepEqual(JSON.parse(String(init?.body)).input, { type: "retry" }); + m.adoptRecord(compacted); + return Response.json({ error: refused }, { status: 400 }); + }, + }); + await m.send({ type: "retry" }); + assert.equal(m.getState().status, "error"); + assert.equal(JSON.parse(m.getState().error!.message).error, refused, "the existing refusal-envelope path carries both messages"); + assert.deepEqual(m.getState().nativeMessages, compacted); + assert.equal(endsInFailedTurn(m.getState().nativeMessages), false); + }); + it("after a dropped request, Retry posts straight from the error status", async () => { - const failedTurn = [ - { ...newUserMessage("hello"), id: "u1" }, - { ...fauxAssistantMessage("", { stopReason: "error", errorMessage: "fetch failed" }), id: "a1" }, - ] as unknown as LhMessage[]; + const failedTurn = [newUserMessage("hello"), failed("a1")]; const posted: string[] = []; let online = false; const fetch = (async (_url: string, init?: RequestInit) => { if (!online) throw new TypeError("Failed to fetch"); if (init?.method === "POST") posted.push(String(init.body)); - return new Response("{}", { status: 500 }); // the reattach is not under test + return new Response("{}", { status: 500 }); }) as typeof globalThis.fetch; const m = new SessionModel({ chatId: "chat_x", initialMessages: failedTurn, fetch }); - await m.send({ type: "retry" }); - assert.equal(m.getState().status, "error", "the dropped request leaves the model in error"); + assert.equal(m.getState().status, "error"); online = true; await m.send({ type: "retry" }); assert.equal(posted.length, 1, "send() recovers from error without stop()"); assert.deepEqual(JSON.parse(posted[0]!).input, { type: "retry" }); + assert.deepEqual(m.getState().nativeMessages, failedTurn, "there is no optimistic learner message or deletion"); }); }); -describe("retryDrops (pure)", () => { - const failed = (id: string): LhMessage => - ({ ...fauxAssistantMessage("", { stopReason: "error", errorMessage: "boom" }), id }) as unknown as LhMessage; - - it("parses the retry input event", () => { - assert.deepEqual(parseChatInput({ type: "retry" }), { type: "retry" }); +describe("Retry eligibility and temporary omissions (pure)", () => { + it("parses Retry without appending input", () => { + const input = parseChatInput({ type: "retry" }); + assert.deepEqual(input, { type: "retry" }); + assert.deepEqual(inputAppends({ messages: [newUserMessage("hi"), failed("a1")] }, input), []); }); - it("drops every trailing failure back to the input", () => { - const user = { ...newUserMessage("hi"), id: "u1" } as LhMessage; - assert.deepEqual([...retryDrops([user, failed("a1"), failed("a2")])].sort(), ["a1", "a2"]); + it("omits consecutive failures back to their input without mutating the record", () => { + const messages = [newUserMessage("hi"), failed("a1"), failed("a2")]; + const before = JSON.parse(JSON.stringify(messages)); + assert.deepEqual([...retryDrops(messages)].sort(), ["a1", "a2"]); + assert.deepEqual(messages, before); }); - it("looks past trailing UI-only markers, and keeps them", () => { - const user = { ...newUserMessage("hi"), id: "u1" } as LhMessage; + it("looks past plan blocks, but compaction after the latest failure blocks Retry", () => { + const user = newUserMessage("hi"); + const failure = failed("a1"); const block = newPlanBlockMessage("plan-1", {}); - assert.deepEqual([...retryDrops([user, failed("a1"), block])], ["a1"]); - assert.equal(endsInFailedTurn([user, block]), false, "a marker alone is no failure"); - const compaction = newCompactionMarker("summary", { firstKeptMessageId: "u1", tokensBefore: 100, model: "fixture", at: "2026-01-01T00:00:00Z" }); - assert.deepEqual([...retryDrops([user, failed("a1"), compaction])], ["a1"], "the settle kick's marker too"); + const marker = compaction(user.id); + assert.deepEqual([...retryDrops([user, failure, block])], ["a1"]); + assert.equal(endsInFailedTurn([user, block]), false); + for (const messages of [[user, failure, marker], [user, failure, marker, block], [user, failure, block, marker]]) { + assert.equal(endsInFailedTurn(messages), false); + assert.equal(failedResponseAtEnd(messages)?.id, failure.id, "only the Retry button disappears, not the banner"); + assert.throws(() => retryDrops(messages), /fetch failed[\s\S]*cannot be retried after compaction/); + } + assert.deepEqual([...retryDrops([user, failed("old"), marker, failed("new")])], ["new"], "a failure after compaction can retry its already-compacted starting state"); }); - it("throws when the record does not end in a failure, or nothing precedes it", () => { - const user = { ...newUserMessage("hi"), id: "u1" } as LhMessage; - assert.throws(() => retryDrops([user]), ChatInputError); - assert.throws(() => retryDrops([failed("a1")]), ChatInputError); + it("excludes aborts, healthy responses, missing input and assistant-only predecessors", () => { + const user = newUserMessage("hi"); + const aborted = { ...failed("aborted"), stopReason: "aborted" } as LhMessage; + const healthy = { ...fauxAssistantMessage("Done"), id: "healthy" } as LhMessage; + for (const messages of [[user], [failed("a1")], [user, aborted], [user, healthy], [healthy, failed("a1")]]) { + assert.equal(endsInFailedTurn(messages), false); + assert.throws(() => retryDrops(messages), ChatInputError); + } + assert.equal(failedResponseAtEnd([user, failed("old"), aborted]), undefined); }); }); diff --git a/tests/session-core.test.ts b/tests/session-core.test.ts index 27c3110..0e98e80 100644 --- a/tests/session-core.test.ts +++ b/tests/session-core.test.ts @@ -25,7 +25,7 @@ import { absorbed, pendingCallTool, SessionModel } from "../components/session-c import type { ChatInput } from "../src/core/input-events.ts"; import type { LhMessage } from "../src/core/native-messages.ts"; import { newUserMessage, newAnswerResult, newThinkingMessage, needsContinuation, endsInDismissal } from "../src/core/native-messages.ts"; -import { newCompactionMarker, newPlanBlockMessage, projectView } from "../src/core/index.ts"; +import { endsInFailedTurn, newCompactionMarker, newPlanBlockMessage, projectView } from "../src/core/index.ts"; import { dismissedAnswer } from "../src/core/question-core.ts"; import { buildAnswer, type QuestionData } from "../src/core/question-core.ts"; @@ -483,7 +483,7 @@ it("Wave C: markers injected mid-run (custom role, follow-up delivery) append to }); }); -// ─── provider failures: one final-entry rule for live and saved records ────── +// ─── provider failures: one marker-aware rule for live and saved records ──── describe("provider-error banner (live and saved)", () => { function failed(id = "failed", errorMessage: string | undefined = "Google 500: fixture failure"): LhMessage { @@ -512,7 +512,7 @@ describe("provider-error banner (live and saved)", () => { assert.equal(model().getState().error, null); }); - it("any later entry supersedes the failure, including plan/compaction markers and aborts", () => { + it("substantive later input/output supersedes the failure, including aborts", () => { const failure = failed(); const later: LhMessage[] = [ newUserMessage("retry"), @@ -520,8 +520,6 @@ describe("provider-error banner (live and saved)", () => { { ...assistantMsg("aborted", ""), stopReason: "aborted", errorMessage: "Aborted" } as LhMessage, newThinkingMessage("problem", "my attempt"), resultMsg("call"), - newPlanBlockMessage("plan", { kind: "full", nodes: [], edges: [], current: null }), - newCompactionMarker("summary", { firstKeptMessageId: failure.id, tokensBefore: 100, model: "fixture", at: "2026-01-01T00:00:00Z" }), ]; for (const entry of later) { const m = model({ initialMessages: [failure] }); @@ -533,6 +531,31 @@ describe("provider-error banner (live and saved)", () => { } }); + it("plan and compaction markers preserve the banner on sync, adoption and reopen", async () => { + const failure = failed(); + const record = [newUserMessage("topic"), failure]; + const markers = [ + newPlanBlockMessage("plan", { kind: "full", nodes: [], edges: [], current: null }), + newCompactionMarker("summary", { firstKeptMessageId: failure.id, tokensBefore: 100, model: "fixture", at: "2026-01-01T00:00:00Z" }), + ]; + for (const marker of markers) { + const saved = [...record, marker]; + let posts = 0; + const m = model({ initialMessages: record, fetch: async () => { posts++; throw new Error("unexpected request"); } }); + m.setMessages(saved); + assert.equal(m.getState().error?.message, failure.role === "assistant" ? failure.errorMessage : undefined); + assert.equal(model({ initialMessages: saved }).getState().error?.message, m.getState().error?.message); + const adopted = model({ initialMessages: record }); + adopted.adoptRecord(saved); + assert.equal(adopted.getState().error?.message, m.getState().error?.message); + assert.equal(endsInFailedTurn(saved), marker.customType === "plan-block", "later compaction removes Retry eligibility, not the error notice"); + await m.continueIfOwed(saved); + assert.equal(posts, 0, "restoring the banner is never an automatic retry"); + m.setMessages([...saved, newUserMessage("continue from here")]); + assert.equal(m.getState().error, null, "new learner input supersedes the notice"); + } + }); + it("a newer live message supersedes the failure before its response finishes", () => { const m = model(); m.applyWireEvent({ type: "message_end", message: failed() }); @@ -554,7 +577,7 @@ describe("provider-error banner (live and saved)", () => { assert.equal(m.getState().error?.message, "Google 500: fixture failure"); }); - for (const input of [{ type: "text", text: "retry" }, { type: "continue" }] as const) { + for (const input of [{ type: "text", text: "retry" }, { type: "continue" }, { type: "retry" }] as const) { it(`hides the old failure during the optimistic ${input.type} wait, then shows a new live failure`, async () => { let acknowledge!: (response: Response) => void; const ack = new Promise((resolve) => { acknowledge = resolve; }); From f24fda926a40c1463e20781dafbeb1cd3baa1a94 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 18:19:38 +0200 Subject: [PATCH 40/87] fix: finish new-session opening-message handoff --- components/chat.tsx | 91 ++++++++--- components/home.tsx | 36 ++--- components/new-session.tsx | 62 ++++---- components/opening-prompt.ts | 68 +++++--- components/session-core.ts | 21 ++- components/session.ts | 3 +- components/start-session.ts | 34 ++++ scripts/smoke-opening-prompt.mjs | 251 ++++++++++++++++++++++++++++++ tests/opening-delivery.test.ts | 83 ++++++++++ tests/opening-prompt.test.ts | 123 +++++++++++++++ tests/render-new-session.test.tsx | 24 ++- 11 files changed, 689 insertions(+), 107 deletions(-) create mode 100644 components/start-session.ts create mode 100644 scripts/smoke-opening-prompt.mjs create mode 100644 tests/opening-delivery.test.ts create mode 100644 tests/opening-prompt.test.ts diff --git a/components/chat.tsx b/components/chat.tsx index 11bcd0a..52f93bd 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -26,7 +26,7 @@ import { Composer } from "@/components/composer"; import { useEnterSubmits } from "@/components/enter-submits"; import { blurAfterPointerClick } from "@/components/focus-hygiene"; import { dockStateKey } from "@/components/dock-state-key"; -import { clearOpeningPrompt, readOpeningPrompt } from "@/components/opening-prompt"; +import { clearOpeningPrompt, readOpeningPromptEntry, stashOpeningPrompt, OPENING_PROMPT_FAILURE } from "@/components/opening-prompt"; import { forwardDockWheel, useDockHeight } from "@/components/dock-height"; import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers"; import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation"; @@ -238,7 +238,7 @@ export function Chat({ // (core/view.ts projectView); sends are ONE explicit input event each // ({chatId, input} — the stash indirection died with the SDK), and the // turn stream is the VERBATIM AgentEvent passthrough the server emits. - const { messages, nativeMessages, contextTokens, send, continueIfOwed, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, questionDraft, saveQuestionDraft, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } = + const { messages, nativeMessages, hasConfirmedConversation, contextTokens, send, continueIfOwed, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, questionDraft, saveQuestionDraft, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } = useChatSession({ chatId, initialMessages, initialError, initiallyInFlight }); const sessionFindScopeRef = useRef(null); @@ -248,28 +248,6 @@ export function Chat({ // The reattach settled terminal → the sync-and-continue seam (below). setReattachSettled(() => reattachSettledRef.current()); - // New chat with a topic: send the first message exactly once - // (initialPromptOwed — the pure gate, pinned in the test suite). - // The topic arrives as the server's ?prompt= (old links) or from the - // sessionStorage stash the composer wrote before navigating here. - // The stash stays until send resolves, so a reload before the POST - // lands can retry. sentInitial blocks a second send on this mount. - const sentInitial = useRef(false); - useEffect(() => { - const prompt = initialPrompt || readOpeningPrompt(chatId); - if (!sentInitial.current && prompt && initialPromptOwed(initialMessages, prompt)) { - sentInitial.current = true; - void send({ type: "text", text: prompt }).then( - (landed) => { - if (landed) clearOpeningPrompt(chatId); - }, - () => { - /* leave the stash: the next mount retries */ - }, - ); - } - }, [chatId, initialPrompt, initialMessages, send]); - // A turn running server-side, attached to no live view of ours (navigate // away mid-turn, or a wake turn discovered mid-session): the reattach // stream is the primary channel (resumeStream — the same rendering path @@ -579,6 +557,66 @@ export function Chat({ * Auto-continuations (probe answers) never set it — their refusal * opens recovery with no replay. */ const draftRef = useRef(null); + const openingDraftRef = useRef(null); + const [openingError, setOpeningError] = useState(false); + const openingMountedRef = useRef(false); + useEffect(() => { + openingMountedRef.current = true; + return () => { openingMountedRef.current = false; }; + }, []); + + const deliverOpeningPrompt = useCallback((prompt: string) => { + openingDraftRef.current = prompt; + // Persist uncertainty BEFORE the POST: another mount must restore, + // not race the still-arriving request. No storage means no auto-send. + if (!stashOpeningPrompt(chatId, prompt, true)) { + applyText(prompt); + setOpeningError(true); + return; + } + setOpeningError(false); + draftRef.current = prompt; // The existing 503 flow must restore it too. + applyText(""); + void send({ type: "text", text: prompt }, () => { + clearOpeningPrompt(chatId); + openingDraftRef.current = null; + if (draftRef.current === prompt) draftRef.current = null; + }).then(accepted => { + if (!accepted && openingMountedRef.current && openingDraftRef.current === prompt && !textRef.current) { + applyText(prompt); + } + }); + }, [chatId, send, applyText]); + + const sentInitial = useRef(false); + useEffect(() => { + if (sentInitial.current) return; + sentInitial.current = true; + const entry = readOpeningPromptEntry(chatId); + const prompt = initialPrompt || entry?.text; // Legacy links still work. + if (!initialPromptOwed(initialMessages, prompt)) { + if (initialMessages.some(m => m.role === "user" || m.role === "assistant")) clearOpeningPrompt(chatId); + return; + } + openingDraftRef.current = prompt!; + if (entry?.attempted || initiallyInFlight) { + applyText(prompt!); // Interrupted start: the learner explicitly resends. + return; + } + deliverOpeningPrompt(prompt!); + }, [chatId, initialPrompt, initialMessages, initiallyInFlight, deliverOpeningPrompt, applyText]); + + useEffect(() => { + if (!hasConfirmedConversation) return; + clearOpeningPrompt(chatId); + // A later canonical read may confirm a lost acknowledgment. Clear + // only the unchanged recovered draft, never the learner's newer work. + const opening = openingDraftRef.current; + if (opening !== null && textRef.current === opening) applyText(""); + if (draftRef.current === opening) draftRef.current = null; + openingDraftRef.current = null; + setOpeningError(false); + }, [chatId, hasConfirmedConversation, applyText]); // THE BYOK REFUSAL (owner, 2026-09-22): a send that lands the server's // 503 models_not_configured envelope renders as the SETUP FLOW, never @@ -1155,6 +1193,10 @@ export function Chat({ openModelPicker(); return; // No optimistic send and no change to the learner's draft. } + if (openingDraftRef.current !== null) { + deliverOpeningPrompt(t); + return; + } draftRef.current = t; // the refusal flow's preserve-and-replay seam applyText(""); void send({ type: "text", text: t }); @@ -1586,6 +1628,7 @@ export function Chat({ )} + {openingError &&

{OPENING_PROMPT_FAILURE}

} {error && (
Something went wrong: {error.message} diff --git a/components/home.tsx b/components/home.tsx index c999473..0388291 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -12,7 +12,8 @@ import { DictationBar, joinDictation, useDictation } from "@/components/dictatio import { useFieldCycle } from "@/components/field-cycle"; import { SidebarToggle } from "@/components/sidebar"; import { StatsBar } from "@/components/stats-bar"; -import { stashOpeningPrompt } from "@/components/opening-prompt"; +import { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt"; +import { SessionStarter } from "@/components/start-session"; import type { TurnUsage } from "@/src/core"; /** @@ -57,6 +58,9 @@ export function Home({ }, }); const [busy, setBusy] = useState(false); + const startingRef = useRef(false); + const starterRef = useRef(new SessionStarter()); + const [startError, setStartError] = useState(false); /* Run end focuses the topic input (owner, 2026-10-01): the hands are on * the keyboard from the stop. Falling edge only; this surface's only * dictation target IS this textarea, so no case-split is needed. */ @@ -106,7 +110,7 @@ export function Home({ }; const begin = async () => { const t = topicRef.current.trim(); - if (!t || busy) return; + if (!t || startingRef.current) return; // Unconfigured: the send INTERCEPTS into the setup flow (the draft // stays in the textarea; the learner sends it after Save). if (!configured) { @@ -129,32 +133,25 @@ export function Home({ openModelPicker(); return; // Preserve the draft and never create an orphan session. } + startingRef.current = true; setBusy(true); + setStartError(false); try { // The map context (owner, 2026-10-04): picks in EITHER mode ride // the CREATION as graphContext — the server writes a // graph-context marker as message[0] (a separate custom message // before the learner's first user message, propositions + the // chains among them); the prompt stays the learner's own words. - const res = await fetch("/api/chats", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - title: t, - ...(mode === "practice" ? { kind: "practice" } : {}), - ...(practiceProps.length > 0 ? { graphContext: { propositions: practiceProps } } : {}), - }), - }); - const { id } = (await res.json()) as { id: string }; - // No settings PUT on create (per-session models, owner - // 2026-09-28): the SERVER stamps the newborn from the strong - // slot — the same default this surface's model control edits. - // The opening text stays out of the URL (opening-prompt.ts): - // a long paste as ?prompt= is a 431 and a blank window. - stashOpeningPrompt(id, t); - router.push(`/chat/${id}`); + const target = await starterRef.current.start({ + title: t, + ...(mode === "practice" ? { kind: "practice" } : {}), + ...(practiceProps.length > 0 ? { graphContext: { propositions: practiceProps } } : {}), + }, fetch, JSON.stringify(catalog?.slots.strong)); + router.push(target); } catch { + startingRef.current = false; setBusy(false); + setStartError(true); } }; @@ -243,6 +240,7 @@ export function Home({ ) )} /> + {startError &&

{OPENING_PROMPT_FAILURE}

}
{ diff --git a/components/new-session.tsx b/components/new-session.tsx index 9d60ed1..f8e4c63 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -12,7 +12,8 @@ import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill import { StatsBar } from "@/components/stats-bar"; import { ChainTableOverlay } from "@/components/chain-table.tsx"; import { ComposerActions, NodePickerControl } from "@/components/composer-actions"; -import { stashOpeningPrompt } from "@/components/opening-prompt"; +import { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt"; +import { SessionStarter } from "@/components/start-session"; import type { ChatSettings, GraphSnapshot } from "@/src/core"; /** The draft's pick set (owner round 10): the SAME shape the home pick @@ -34,20 +35,18 @@ export type DraftPick = { id: string; name: string; statement: string }; const DOCK = "mx-auto w-full max-w-[46rem] px-4"; /** - * The draft's whole behavior as one pure seam: create the session (title = - * the first message), persist the lifted pre-chat settings, stash the - * opening text beside the navigation, and return the push target — - * /chat/. The chat page reads the stash and delivers it as turn 1. - * The text never rides the URL: a long paste exceeds Node's header limit - * and the window commits an empty document. Injected fetch so tests pin - * the contract. Returns null on empty text; rethrows on transport failure - * (the caller's busy flag resets). + * Create, safely hand over the full opening text, then return a short + * /chat/ target. Storage/creation failures leave the caller's draft + * intact. A per-composer starter reuses an already-created session on an + * unchanged retry. The server stamps settings at birth; no settings PUT. */ export async function startDraftSession( text: string, fetchLike: typeof fetch, mode?: "practice", picks?: readonly DraftPick[], + starter = new SessionStarter(), + settingsIdentity?: string, ): Promise { const t = text.trim(); if (!t) return null; @@ -55,20 +54,13 @@ export async function startDraftSession( // from the pick bar creates its session WITH the graph context — the // same contract as the home screen's practice send (the marker with // [id] name: statement lines, target validation's truth set). - const res = await fetchLike("/api/chats", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify( - mode === "practice" - ? { title: t, kind: "practice", ...(picks && picks.length > 0 ? { graphContext: { propositions: picks } } : {}) } - : { title: t }, - ), - }); - const { id } = (await res.json()) as { id: string }; - // No settings PUT on create (per-session models, owner 2026-09-28): - // the SERVER stamps the newborn from the strong slot. - stashOpeningPrompt(id, t); - return `/chat/${id}`; + return starter.start( + mode === "practice" + ? { title: t, kind: "practice", ...(picks && picks.length > 0 ? { graphContext: { propositions: picks } } : {}) } + : { title: t }, + fetchLike, + settingsIdentity, + ); } /** The pure shell (no router) — the render-test seam: the draft view's @@ -81,6 +73,7 @@ export function DraftShell({ text, onChange, onSend, + startError = false, setupCta, mode = "teach", picks = [], @@ -92,6 +85,7 @@ export function DraftShell({ text: string; onChange: (v: string) => void; onSend: () => void; + startError?: boolean; /** The practice draft (owner, 2026-10-04): ?mode=practice — the * created session carries kind:"practice" and the copy changes * (the practice agent opens; no probing). */ @@ -194,6 +188,7 @@ export function DraftShell({ )} setup={setupCta} /> + {startError &&

{OPENING_PROMPT_FAILURE}

}
{ const draft = textRef.current.trim(); - if (!draft || busy) return; + if (!draft || startingRef.current) return; if (!configured) { setSetupOpen(true); return; @@ -304,19 +302,27 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi openModelPicker(); return; // The learner replaces it explicitly, then sends again. } + startingRef.current = true; setBusy(true); - void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined) + setStartError(false); + void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined, + starterRef.current, JSON.stringify(catalog?.slots.strong)) .then((target) => { if (target) router.push(target); - else setBusy(false); + else { startingRef.current = false; setBusy(false); } }) - .catch(() => setBusy(false)); - }, [busy, router, configured, zeroKeys, catalog]); + .catch(() => { + startingRef.current = false; + setBusy(false); + setStartError(true); + }); + }, [router, configured, zeroKeys, catalog, mode]); return ( <> ?prompt=`. - * Node's default max HTTP header size is 16KB. A long paste (a syllabus) - * makes that GET return 431, and Electron commits an empty document: the - * window stays the native background and looks frozen. The chat record - * already exists, with an empty transcript, because POST /api/chats - * succeeded first. - * - * sessionStorage holds the trimmed text under `lh.openingPrompt.`. - * The push target stays `/chat/`. The chat page still honors a - * `?prompt=` search param for an old link; when that param is absent it - * reads this stash and sends it once. The stash survives until that send - * resolves, so a reload before the POST lands can retry. Absent storage - * (node tests, a thrown access) is a no-op — the short path still returns. + * Same-tab handover of a new session's full opening text. Never put the + * message in the navigation URL: long query payloads can be rejected before + * the chat page loads. Storage is required for automatic delivery, not an + * optional best-effort side effect of navigating away from the source draft. */ +export const OPENING_PROMPT_FAILURE = "Couldn’t open the session. Your message is still here. Try sending again."; + +export interface OpeningPrompt { + text: string; + /** Written BEFORE sending. A later mount restores a draft, never reposts. */ + attempted: boolean; +} const keyFor = (id: string) => `lh.openingPrompt.${id}`; -function store(): Storage | null { +export function stashOpeningPrompt(id: string, text: string, attempted = false): boolean { try { - if (typeof sessionStorage === "undefined" || sessionStorage == null) return null; - return sessionStorage; + const value = JSON.stringify({ text, attempted } satisfies OpeningPrompt); + // Catch both the storage getter and its methods (quota/security errors). + const storage = globalThis.sessionStorage; + storage.setItem(keyFor(id), value); + return storage.getItem(keyFor(id)) === value; } catch { - return null; + return false; } } -export function stashOpeningPrompt(id: string, text: string): void { - store()?.setItem(keyFor(id), text); +export function readOpeningPromptEntry(id: string): OpeningPrompt | undefined { + try { + const value = globalThis.sessionStorage.getItem(keyFor(id)); + if (!value) return undefined; + try { + const entry: unknown = JSON.parse(value); + if (entry && typeof entry === "object" && "text" in entry && typeof entry.text === "string" && + entry.text.length > 0 && "attempted" in entry && typeof entry.attempted === "boolean") { + return { text: entry.text, attempted: entry.attempted }; + } + } catch { /* Old raw-text stashes have no proof that sending never began. */ } + return { text: value, attempted: true }; + } catch { + return undefined; + } } export function readOpeningPrompt(id: string): string | undefined { - const value = store()?.getItem(keyFor(id)); - return value && value.length > 0 ? value : undefined; + return readOpeningPromptEntry(id)?.text; } -export function clearOpeningPrompt(id: string): void { - store()?.removeItem(keyFor(id)); +export function clearOpeningPrompt(id: string): boolean { + try { + globalThis.sessionStorage.removeItem(keyFor(id)); + return true; + } catch { + // Canonical conversation/acknowledgment remains the truth. A later + // mount with real conversation can try cleanup again; never crash it. + return false; + } } diff --git a/components/session-core.ts b/components/session-core.ts index 35d2021..8c53aa4 100644 --- a/components/session-core.ts +++ b/components/session-core.ts @@ -179,6 +179,8 @@ function sameDetails(a: unknown, b: unknown): boolean { export interface SessionState { messages: LhViewMessage[]; nativeMessages: LhMessage[]; + /** Server-read history only; optimistic bubbles are not delivery proof. */ + hasConfirmedConversation: boolean; /** Completed conversation usage; null after compaction or before first usage. */ contextTokens: number | null; status: SessionStatus; @@ -298,6 +300,7 @@ export class SessionModel { return { messages: projectView(messages, live), nativeMessages: messages, + hasConfirmedConversation: this.base.some(m => m.role === "user" || m.role === "assistant"), contextTokens: measuredContextTokens(messages, this.incompleteUsageIds), status: this.statusValue, error: this.errorValue ?? providerError, @@ -636,7 +639,7 @@ export class SessionModel { * POST + consume. Ready-gated: one turn per client at a time (the * server's 409 + identity retry covers the wake-race window). */ - async send(input: ChatInput): Promise { + async send(input: ChatInput, onAccepted?: () => void): Promise { const recovering = this.statusValue === "error"; if (!this.readyRef && !recovering) return false; this.consumeAbort?.abort(); @@ -721,6 +724,8 @@ export class SessionModel { throw new Error((await res.text()) || `The send failed (${res.status}).`); } accepted = true; + // Opening-message handover ends at acceptance, not stream completion. + onAccepted?.(); // CONNECTION DIET B (owner, 2026-10-10 — parallel-session freeze // postmortem): the POST acknowledges and closes; there is no stream // on this response to consume. Attach through the SAME reattach wire @@ -737,10 +742,10 @@ export class SessionModel { return true; } catch (err) { if (this.consumeAbort !== controller || controller.signal.aborted) return accepted; - // Restore the question when acceptance failed or is unknown. Remove - // only THIS optimistic answer, never saved copies/newer live content. - // Other input kinds retain their existing optimistic presentation. - if (input.type === "answer") { + // Restore the question, or the new-session opening draft, when + // acceptance failed/is unknown. Other text sends keep their existing + // optimistic presentation; remove only THIS attempt's local entries. + if (input.type === "answer" || (input.type === "text" && onAccepted && !accepted)) { const ids = new Set(optimistic.map(m => m.id)); this.mutate(() => { this.tail = this.tail.filter(m => !ids.has(m.id)); @@ -749,9 +754,9 @@ export class SessionModel { } this.setError((err as Error).message); this.setStatus("error"); - // true only once the POST ack landed. A throw before that (the - // fetch, or !res.ok) leaves the message unsent — the caller keeps - // its opening-text stash and a reload can retry. + // true means we observed this POST's acknowledgment. false does + // NOT prove non-delivery: a network failure may hide acceptance. + // Opening-message callers restore a draft, never automatically retry. return accepted; } } diff --git a/components/session.ts b/components/session.ts index 3c35d3b..4b1cbb5 100644 --- a/components/session.ts +++ b/components/session.ts @@ -102,7 +102,7 @@ export function useChatSession({ chatId, initialMessages, initialError, initiall [model], ); - const send = useCallback((input: ChatInput) => model.send(input), [model]); + const send = useCallback((input: ChatInput, onAccepted?: () => void) => model.send(input, onAccepted), [model]); const continueIfOwed = useCallback((record: readonly LhMessage[]) => void model.continueIfOwed(record), [model]); const steer = useCallback((text: string) => void model.steer(text), [model]); const submitAnswer = useCallback( @@ -129,6 +129,7 @@ export function useChatSession({ chatId, initialMessages, initialError, initiall return { messages: state.messages, nativeMessages: state.nativeMessages, + hasConfirmedConversation: state.hasConfirmedConversation, contextTokens: state.contextTokens, status: state.status, error: state.error, diff --git a/components/start-session.ts b/components/start-session.ts new file mode 100644 index 0000000..1db399b --- /dev/null +++ b/components/start-session.ts @@ -0,0 +1,34 @@ +import { clearOpeningPrompt, OPENING_PROMPT_FAILURE, stashOpeningPrompt } from "./opening-prompt"; + +interface SessionCreation { + title: string; + kind?: "practice"; + graphContext?: { propositions: readonly { id: string; name: string; statement: string }[] }; +} + +/** Per source-composer instance. Retain a successful creation if storage + * fails, so an unchanged retry does not create another empty session. + * Changed text/context/settings is a different start, not a stale title. + */ +export class SessionStarter { + private created?: { identity: string; id: string }; + + async start(body: SessionCreation, fetchLike: typeof fetch, settingsIdentity?: string): Promise { + const identity = JSON.stringify([body, settingsIdentity]); + if (this.created?.identity !== identity) { + if (this.created) clearOpeningPrompt(this.created.id); + this.created = undefined; + const res = await fetchLike("/api/chats", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(OPENING_PROMPT_FAILURE); + const result = await res.json() as { id?: unknown }; + if (typeof result.id !== "string" || !/^[A-Za-z0-9_-]+$/.test(result.id)) throw new Error(OPENING_PROMPT_FAILURE); + this.created = { identity, id: result.id }; + } + if (!stashOpeningPrompt(this.created.id, body.title)) throw new Error(OPENING_PROMPT_FAILURE); + return `/chat/${this.created.id}`; + } +} diff --git a/scripts/smoke-opening-prompt.mjs b/scripts/smoke-opening-prompt.mjs new file mode 100644 index 0000000..c1f4665 --- /dev/null +++ b/scripts/smoke-opening-prompt.mjs @@ -0,0 +1,251 @@ +/** Focused synthetic live-UI check. No model/provider calls: /api/chat is + * intercepted, catalog is a fixture, app HOME/data/auth are throwaway, and + * the browser uses a fresh profile. Creates only synthetic session records. + * Run: node scripts/smoke-opening-prompt.mjs + */ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import fs from "node:fs"; +import net from "node:net"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import puppeteer from "puppeteer-core"; + +const root = path.resolve(import.meta.dirname, ".."); +const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh154-ui-")); +fs.mkdirSync(path.join(home, "data")); +fs.writeFileSync(path.join(home, "auth.json"), "{}\n"); +// Next's supported font fixture avoids external font downloads in this check. +const require = createRequire(import.meta.url); +const { getFontAxes } = require("next/dist/compiled/@next/font/dist/google/get-font-axes.js"); +const { getGoogleFontsUrl } = require("next/dist/compiled/@next/font/dist/google/get-google-fonts-url.js"); +const fonts = Object.fromEntries(["JetBrains Mono", "Source Sans 3"].map(family => [ + getGoogleFontsUrl(family, getFontAxes(family, ["variable"], ["normal"]), "swap"), + `@font-face { font-family: '${family}'; src: local('Arial'); font-weight: 100 900; }`, +])); +fs.writeFileSync(path.join(home, "fonts.cjs"), `module.exports = ${JSON.stringify(fonts)};\n`); +const listener = net.createServer(); +listener.listen(0, "127.0.0.1"); +await once(listener, "listening"); +const port = listener.address().port; +await new Promise(resolve => listener.close(resolve)); +const origin = `http://127.0.0.1:${port}`; +const env = { + PATH: process.env.PATH, HOME: home, TMPDIR: home, NODE_ENV: "development", + LH_DATA_DIR: path.join(home, "data"), LH_AUTH_FILE: path.join(home, "auth.json"), + LH_IGNORE_ENV_KEYS: "1", NEXT_TELEMETRY_DISABLED: "1", + NEXT_FONT_GOOGLE_MOCKED_RESPONSES: path.join(home, "fonts.cjs"), +}; +const server = spawn(process.execPath, ["node_modules/next/dist/bin/next", "dev", "--hostname", "127.0.0.1", "--port", String(port)], { + cwd: root, env, stdio: ["ignore", "pipe", "pipe"], +}); +let logs = ""; +const capture = data => { logs = (logs + data).slice(-24000); }; +server.stdout.on("data", capture); server.stderr.on("data", capture); +let browser; +let page; +const errors = []; +const checks = []; +try { + await new Promise((resolve, reject) => { + const deadline = setTimeout(() => reject(new Error("app startup timed out")), 60000); + const ready = () => { if (logs.includes("Ready in")) { clearTimeout(deadline); server.stdout.off("data", ready); resolve(); } }; + server.stdout.on("data", ready); + server.once("exit", code => { clearTimeout(deadline); reject(new Error(`app exited ${code}`)); }); + ready(); + }); + browser = await puppeteer.launch({ + executablePath: process.env.LH_TEST_CHROME || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + headless: true, userDataDir: path.join(home, "browser"), env, + args: ["--disable-background-networking", "--disable-sync", "--no-first-run", "--disable-default-apps"], + }); + page = await browser.newPage(); + page.setDefaultTimeout(45000); + page.on("pageerror", err => errors.push(err.message)); + const model = { id: "synthetic", name: "Synthetic (no calls)", provider: "fixture", input: ["text"], reasoning: false, supportedLevels: ["off"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024 }; + const slot = { model: { provider: model.provider, id: model.id }, thinkingLevel: "off" }; + const catalog = { models: [model], providers: [{ id: "fixture", label: "Fixture", keyPresent: true }], defaults: null, + slots: { strong: slot, cheap: slot, vision: slot }, rolePins: {} }; + const posts = []; + const creates = []; + const accepted = new Map(); + let mode = "accepted"; + let held = []; + await page.setRequestInterception(true); + page.on("request", async request => { + try { + const url = new URL(request.url()); + if (url.origin !== origin) { await request.abort(); return; } + const pathname = url.pathname; + const respond = body => request.respond({ status: 200, contentType: "application/json", body: JSON.stringify(body) }); + if (pathname === "/api/models") return await respond(catalog); + // Notifications are outside this handover check. 204 tells native + // EventSource not to reconnect or hold a test connection open. + if (pathname === "/api/events") return await request.respond({ status: 204 }); + if (pathname === "/api/chat") { + const body = JSON.parse(request.postData()); posts.push(body); + if (mode === "held-post") { held.push(request); return; } + if (mode === "lost-ack") return await request.abort("failed"); + if (mode === "refused") return await request.respond({ status: 503, contentType: "application/json", body: JSON.stringify({ code: "models_not_configured", missing: ["strong"] }) }); + accepted.set(body.chatId, [{ id: "saved_user", role: "user", content: body.input.text, timestamp: Date.now() }]); + return await respond({ ok: true }); + } + if (/^\/api\/chats\/[^/]+\/stream$/.test(pathname)) { + // Keep teacher delivery unfinished: cleanup must NOT wait for it. + held.push(request); return; + } + const match = pathname.match(/^\/api\/chats\/([^/]+)$/); + if (match && request.method() === "GET" && accepted.has(match[1])) { + return await respond({ messages: accepted.get(match[1]), turns: [], turnInFlight: false }); + } + if (pathname === "/api/chats" && request.method() === "POST") creates.push(JSON.parse(request.postData())); + await request.continue(); + } catch (err) { if (!/Target closed|Session closed|Invalid InterceptionId/.test(String(err))) errors.push(String(err)); } + }); + async function open(route) { + // CDP-intercepted requests do not settle themselves on navigation. + // Release synthetic streams from prior cases rather than pin slots. + await Promise.all(held.splice(0).map(request => request.abort("aborted").catch(() => {}))); + await page.goto(origin + route, { waitUntil: "domcontentloaded", timeout: 120000 }); + await page.waitForSelector(route === "/" ? "textarea" : '[data-testid="composer"] textarea'); + await page.waitForFunction(() => !document.querySelector('[data-testid="setup-cta"]') && !document.querySelector('[data-testid="setup-cta-keys"]')); + } + async function fill(text) { + await page.evaluate(value => { + const textarea = document.querySelector('[data-testid="composer"] textarea') || document.querySelector("textarea"); + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value").set.call(textarea, value); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }, text); + } + async function submit() { + await page.waitForFunction(() => { + const button = document.querySelector('button[aria-label="Start session"], [data-testid="composer"] button[aria-label="Send"]'); + return button && !button.disabled; + }); + await page.locator('button[aria-label="Start session"], [data-testid="composer"] button[aria-label="Send"]').click(); + } + async function stash(id) { return page.evaluate(chat => sessionStorage.getItem(`lh.openingPrompt.${chat}`), id); } + async function assertDraft(text) { await page.waitForFunction(value => document.querySelector('[data-testid="composer"] textarea')?.value === value, {}, text); } + const long = "A full synthetic syllabus — 世界 & ? #. ".repeat(1200).trim(); + for (const route of ["/", "/chat/new"]) { + for (const text of ["a short synthetic topic", long]) { + await open(route); + const count = posts.length; + await fill(text); await submit(); + await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname) && !location.search); + await page.waitForFunction(() => document.querySelector('[data-testid="composer"]')); + await page.waitForFunction(() => !Object.keys(sessionStorage).some(k => k.startsWith("lh.openingPrompt."))); + assert.equal(posts.length, count + 1); + assert.equal(posts.at(-1).input.text, text); + assert.equal(new URL(page.url()).search, ""); + assert.equal(await stash(posts.at(-1).chatId), null); + checks.push(`${route}: ${text.length > 1000 ? "long" : "short"} input delivered intact through short URL; stash removed while stream held`); + } + } + // The old long-query mechanism really is rejected by this isolated Node server. + const oldResponse = await fetch(`${origin}${new URL(page.url()).pathname}?prompt=${encodeURIComponent(long)}`); + assert.equal(oldResponse.status, 431); await oldResponse.text(); + checks.push("old long-query request returned431 (browser-shell blank appearance not asserted)"); + + for (const route of ["/", "/chat/new"]) { + await open(route); + await page.evaluate(() => { + const setItem = Storage.prototype.setItem; + Storage.prototype.setItem = function(key, value) { + if (window.failOpeningStorage && key.startsWith("lh.openingPrompt.")) throw new DOMException("synthetic quota", "QuotaExceededError"); + return setItem.call(this, key, value); + }; + window.failOpeningStorage = true; + }); + const createCount = creates.length; const sendCount = posts.length; + await fill(long); await submit(); + await page.waitForSelector('[role="alert"]'); + assert.equal(new URL(page.url()).pathname, route); + assert.equal(await page.$eval("textarea", element => element.value), long); + assert.equal(posts.length, sendCount); + assert.equal(creates.length, createCount + 1); + await page.evaluate(() => { window.failOpeningStorage = false; }); + await submit(); + await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname)); + await page.waitForFunction(() => !Object.keys(sessionStorage).some(k => k.startsWith("lh.openingPrompt."))); + assert.equal(creates.length, createCount + 1, "unchanged retry reuses creation"); + assert.equal(posts.at(-1).input.text, long); + checks.push(`${route}: quota failure preserved visible draft/no navigation; retry reused creation`); + } + + for (const failure of ["lost-ack", "refused", "held-post"]) { + mode = failure; + await open("/chat/new"); + const text = `recover interrupted ${failure}`; + await fill(text); await submit(); + await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname)); + await page.waitForFunction(() => Object.values(sessionStorage).some(v => v.includes('"attempted":true'))); + if (failure !== "held-post") await assertDraft(text); + const count = posts.length; + await page.reload({ waitUntil: "domcontentloaded" }); + await assertDraft(text); + assert.equal(posts.length, count, "interrupted start must not automatically repost"); + checks.push(`${failure}: reload restored full draft, no automatic repost`); + if (failure === "lost-ack") { + mode = "accepted"; + await submit(); + const id = new URL(page.url()).pathname.split("/").at(-1); + await page.waitForFunction(chat => !sessionStorage.getItem(`lh.openingPrompt.${chat}`), {}, id); + assert.equal(posts.length, count + 1); + assert.equal(posts.at(-1).input.text, text); + checks.push("restored draft sent only after explicit click and cleaned up on acceptance"); + } + } + mode = "accepted"; + // Marker-only session must still automatically deliver a fresh opening. + await open("/chat/new"); + const markerId = await page.evaluate(async () => { + const res = await fetch("/api/chats", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "marker fixture", graphContext: { propositions: [{ id: "synthetic_node", name: "Fixture", statement: "Synthetic" }] } }) }); + return (await res.json()).id; + }); + await page.evaluate(id => sessionStorage.setItem(`lh.openingPrompt.${id}`, JSON.stringify({ text: "marker opening", attempted: false })), markerId); + const markerCount = posts.length; + await page.goto(`${origin}/chat/${markerId}`, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(id => !sessionStorage.getItem(`lh.openingPrompt.${id}`), {}, markerId); + assert.equal(posts.length, markerCount + 1); + assert.equal(posts.at(-1).input.text, "marker opening"); + checks.push("graph-marker-only record still receives opening text"); + // A real persisted user prevents delivery and cleans a stale copy on mount. + const chatFile = path.join(home, "data", "chats", `${markerId}.json`); + const record = JSON.parse(fs.readFileSync(chatFile, "utf8")); + record.messages.push({ id: "confirmed_fixture", role: "user", content: "already received", timestamp: Date.now() }); + fs.writeFileSync(chatFile, JSON.stringify(record)); + await page.evaluate(id => sessionStorage.setItem(`lh.openingPrompt.${id}`, JSON.stringify({ text: "stale opening", attempted: true })), markerId); + const completedCount = posts.length; + await page.reload({ waitUntil: "domcontentloaded" }); + await page.waitForFunction(id => !sessionStorage.getItem(`lh.openingPrompt.${id}`), {}, markerId); + assert.equal(posts.length, completedCount); + checks.push("persisted real conversation removed stale stash without reposting"); + await open("/chat/new"); + const legacyId = await page.evaluate(async () => { + const response = await fetch("/api/chats", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "legacy fixture" }) }); + return (await response.json()).id; + }); + const legacyCount = posts.length; + await page.goto(`${origin}/chat/${legacyId}?prompt=${encodeURIComponent("legacy opening")}`, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => document.body.innerText.includes("legacy opening")); + await page.waitForFunction(id => !sessionStorage.getItem(`lh.openingPrompt.${id}`), {}, legacyId); + assert.equal(posts.length, legacyCount + 1); + assert.equal(posts.at(-1).input.text, "legacy opening"); + checks.push("legacy short ?prompt link still sends its complete opening message"); + assert.deepEqual(errors, []); + console.log(JSON.stringify({ checks, pageErrors: errors, isolation: "synthetic catalog/POST+streams; empty auth; no providers", origin }, null, 2)); +} catch (err) { + console.error(err); + console.error(JSON.stringify({ checks, errors, url: page?.url(), dom: await page?.evaluate(() => ({ textareas: [...document.querySelectorAll('textarea')].map(e => ({ length: e.value.length, placeholder: e.placeholder })), buttons: [...document.querySelectorAll('button[aria-label="Send"], button[aria-label="Start session"]')].map(e => ({ disabled: e.disabled, label: e.getAttribute('aria-label') })), alerts: [...document.querySelectorAll('[role="alert"]')].map(e => e.textContent), failStorage: window.failOpeningStorage, text: document.body.innerText.slice(-1200) })).catch(() => null) }, null, 2)); + console.error(logs); process.exitCode = 1; +} finally { + await browser?.close(); + const exited = once(server, "exit"); + server.kill("SIGTERM"); + const killTimer = setTimeout(() => server.kill("SIGKILL"), 5000); killTimer.unref(); + await exited; clearTimeout(killTimer); + fs.rmSync(home, { recursive: true, force: true }); +} diff --git a/tests/opening-delivery.test.ts b/tests/opening-delivery.test.ts new file mode 100644 index 0000000..11926bc --- /dev/null +++ b/tests/opening-delivery.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { SessionModel } from "../components/session-core.ts"; +import { newUserMessage } from "../src/core/native-messages.ts"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +describe("new-session opening delivery acceptance", () => { + it("notifies acceptance before the record read or teacher stream completes", async () => { + const record = deferred(); + const accepted = deferred(); + const calls: string[] = []; + const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => { + calls.push(String(url)); + if (String(url) === "/api/chat") return Response.json({ ok: true }); + if (String(url).endsWith("/stream")) return new Response("data: [DONE]\n\n"); + return record.promise; + } }); + let completed = false; + const sending = m.send({ type: "text", text: "my syllabus" }, () => accepted.resolve()).then(result => { completed = true; return result; }); + await accepted.promise; + assert.equal(completed, false); + assert.equal(m.getState().hasConfirmedConversation, false, "optimistic user text is not canonical proof"); + record.resolve(Response.json({ messages: [newUserMessage("my syllabus")] })); + assert.equal(await sending, true); + assert.equal(m.getState().hasConfirmedConversation, true); + assert.equal(calls.filter(url => url === "/api/chat").length, 1); + }); + + it("a teardown racing the POST still cleans up on acknowledgment without attaching", async () => { + const post = deferred(); + let notified = 0; + const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => { + assert.equal(String(url), "/api/chat"); + return post.promise; + } }); + const sending = m.send({ type: "text", text: "hello" }, () => { notified++; }); + m.stop(); + post.resolve(Response.json({ ok: true })); + assert.equal(await sending, true); + assert.equal(notified, 1); + }); + + for (const failure of ["refusal", "409", "lost acknowledgment"] as const) { + it(`${failure} does not notify acceptance or leave a false confirmed conversation`, async () => { + let notified = false; + const m = new SessionModel({ chatId: "c1", initialMessages: [], waitMaxMs: 0, fetch: async () => { + if (failure === "lost acknowledgment") throw new TypeError("connection lost"); + return new Response("refused", { status: failure === "409" ? 409 : 503 }); + } }); + assert.equal(await m.send({ type: "text", text: "keep this" }, () => { notified = true; }), false); + assert.equal(notified, false); + assert.equal(m.getState().hasConfirmedConversation, false); + assert.equal(m.getState().nativeMessages.length, 0, "restore the opening draft, not a stranded optimistic bubble"); + assert.equal(m.status, "error"); + }); + } + + it("post-acceptance attachment failure remains accepted and does not resend input", async () => { + let posts = 0; + let notified = 0; + const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => { + if (String(url) === "/api/chat") { posts++; return Response.json({ ok: true }); } + throw new TypeError("record read failed"); + } }); + assert.equal(await m.send({ type: "text", text: "hello" }, () => { notified++; }), true); + assert.equal(notified, 1); + assert.equal(posts, 1); + assert.equal(m.status, "error"); + }); + + it("a busy gate sends nothing, then canonical adoption can confirm an earlier lost acknowledgment", async () => { + const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async () => { throw new Error("must not post"); } }); + m.readyRef = false; + assert.equal(await m.send({ type: "text", text: "hello" }, () => assert.fail("must not acknowledge")), false); + m.setMessages([newUserMessage("hello")]); + assert.equal(m.getState().hasConfirmedConversation, true); + }); +}); diff --git a/tests/opening-prompt.test.ts b/tests/opening-prompt.test.ts new file mode 100644 index 0000000..f89f659 --- /dev/null +++ b/tests/opening-prompt.test.ts @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { clearOpeningPrompt, readOpeningPrompt, readOpeningPromptEntry, stashOpeningPrompt } from "../components/opening-prompt.ts"; +import { SessionStarter } from "../components/start-session.ts"; + +const original = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"); +let data: Map; +let storage: Pick; +function install(value: unknown) { + Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value }); +} +beforeEach(() => { + data = new Map(); + storage = { + getItem: key => data.get(key) ?? null, + setItem: (key, value) => { data.set(key, value); }, + removeItem: key => { data.delete(key); }, + }; + install(storage); +}); +afterEach(() => { + if (original) Object.defineProperty(globalThis, "sessionStorage", original); + else Reflect.deleteProperty(globalThis, "sessionStorage"); +}); +const fail = () => { throw new Error("synthetic storage failure"); }; + +describe("opening message storage", () => { + it("keeps full text off the URL and records attempted delivery before send", () => { + const text = "a syllabus with Unicode 世界 & ? # ".repeat(1500); + assert.equal(stashOpeningPrompt("c1", text), true); + assert.deepEqual(readOpeningPromptEntry("c1"), { text, attempted: false }); + assert.equal(stashOpeningPrompt("c1", text, true), true); + assert.deepEqual(readOpeningPromptEntry("c1"), { text, attempted: true }); + assert.equal(clearOpeningPrompt("c1"), true); + assert.equal(readOpeningPrompt("c1"), undefined); + }); + + it("treats a legacy raw-text stash conservatively as a possibly attempted send", () => { + data.set("lh.openingPrompt.old", "teach me logic"); + assert.deepEqual(readOpeningPromptEntry("old"), { text: "teach me logic", attempted: true }); + }); + + for (const unavailable of [undefined, null]) { + it(`reports unavailable storage (${unavailable}) instead of claiming a safe handover`, () => { + install(unavailable); + assert.equal(stashOpeningPrompt("c1", "keep this"), false); + assert.equal(readOpeningPrompt("c1"), undefined); + assert.equal(clearOpeningPrompt("c1"), false); + }); + } + it("catches a throwing storage getter", () => { + Object.defineProperty(globalThis, "sessionStorage", { configurable: true, get: fail }); + assert.equal(stashOpeningPrompt("c1", "keep this"), false); + assert.equal(readOpeningPrompt("c1"), undefined); + assert.equal(clearOpeningPrompt("c1"), false); + }); + for (const method of ["getItem", "setItem", "removeItem"] as const) { + it(`contains ${method} exceptions without crashing a source or chat page`, () => { + storage[method] = fail; + assert.doesNotThrow(() => stashOpeningPrompt("c1", "keep this")); + assert.doesNotThrow(() => readOpeningPrompt("c1")); + assert.doesNotThrow(() => clearOpeningPrompt("c1")); + if (method !== "removeItem") assert.equal(stashOpeningPrompt("c1", "keep this"), false); + }); + } + it("does not accept a storage write that failed silently", () => { + storage.setItem = () => {}; + assert.equal(stashOpeningPrompt("c1", "keep this"), false); + }); +}); + +describe("new-session create and handover", () => { + function createFetch() { + const bodies: unknown[] = []; + const fetchLike = (async (_url, init) => { + bodies.push(JSON.parse(String(init?.body))); + return Response.json({ id: `chat_${bodies.length}` }); + }) as typeof fetch; + return { bodies, fetchLike }; + } + it("refuses navigation on quota failure, then reuses the created session for unchanged retry", async () => { + const starter = new SessionStarter(); + const { bodies, fetchLike } = createFetch(); + const text = "full text ".repeat(4000); + const setItem = storage.setItem; + storage.setItem = fail; + await assert.rejects(starter.start({ title: text }, fetchLike)); + assert.equal(bodies.length, 1); + storage.setItem = setItem; + assert.equal(await starter.start({ title: text }, fetchLike), "/chat/chat_1"); + assert.equal(bodies.length, 1); + assert.equal(readOpeningPrompt("chat_1"), text); + }); + it("reuses creation even when verifying the first storage write failed", async () => { + const starter = new SessionStarter(); + const { bodies, fetchLike } = createFetch(); + const getItem = storage.getItem; + storage.getItem = fail; + await assert.rejects(starter.start({ title: "hello" }, fetchLike)); + storage.getItem = getItem; + assert.equal(await starter.start({ title: "hello" }, fetchLike), "/chat/chat_1"); + assert.equal(bodies.length, 1); + }); + it("changed text, mode, picks or birth settings create a matching new session", async () => { + const starter = new SessionStarter(); + const { bodies, fetchLike } = createFetch(); + await starter.start({ title: "original" }, fetchLike, "model-a"); + await starter.start({ title: "edited" }, fetchLike, "model-a"); + await starter.start({ title: "edited", kind: "practice" }, fetchLike, "model-a"); + const picked = { title: "edited", kind: "practice" as const, graphContext: { propositions: [{ id: "p1", name: "P", statement: "S" }] } }; + await starter.start(picked, fetchLike, "model-a"); + await starter.start(picked, fetchLike, "model-b"); + assert.equal(bodies.length, 5); + assert.equal(readOpeningPrompt("chat_1"), undefined); + }); + it("does not stash or navigate on a refused creation or invalid ID", async () => { + for (const response of [new Response("unavailable", { status: 503 }), Response.json({}), Response.json({ id: "../bad?prompt=leak" })]) { + const starter = new SessionStarter(); + await assert.rejects(starter.start({ title: "hello" }, (async () => response) as typeof fetch)); + assert.equal(data.size, 0); + } + }); +}); diff --git a/tests/render-new-session.test.tsx b/tests/render-new-session.test.tsx index e62a550..bde25da 100644 --- a/tests/render-new-session.test.tsx +++ b/tests/render-new-session.test.tsx @@ -2,10 +2,24 @@ import React from "react"; (globalThis as Record).React = React; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, it } from "node:test"; +import { afterEach, beforeEach, describe, it } from "node:test"; import assert from "node:assert/strict"; import { DraftShell, startDraftSession } from "../components/new-session.tsx"; -import { clearOpeningPrompt, readOpeningPrompt } from "../components/opening-prompt.ts"; +import { clearOpeningPrompt, readOpeningPrompt, OPENING_PROMPT_FAILURE } from "../components/opening-prompt.ts"; + +const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"); +beforeEach(() => { + const data = new Map(); + Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { data.set(key, value); }, + removeItem: (key: string) => { data.delete(key); }, + } }); +}); +afterEach(() => { + if (originalStorage) Object.defineProperty(globalThis, "sessionStorage", originalStorage); + else Reflect.deleteProperty(globalThis, "sessionStorage"); +}); /** * The sessionless draft view (/chat/new, owner 2026-09-13): "+ New session" @@ -82,6 +96,12 @@ describe("startDraftSession — the create-on-send contract", () => { }); describe("DraftShell — the sessionless surface", () => { + it("storage failure leaves the full draft editable beside the approved error", () => { + const html = renderToStaticMarkup( {}} onSend={() => {}} startError />); + assert.ok(html.includes('role="alert"')); + assert.ok(html.includes(OPENING_PROMPT_FAILURE)); + assert.match(html, /]*>keep all my text<\/textarea>/); + }); it("renders the composer blob, the stats bar, and the fresh-chat empty state", () => { const html = renderToStaticMarkup( Date: Tue, 27 Oct 2026 18:35:31 +0200 Subject: [PATCH 41/87] fix: render failed-response retry refusals as readable text Decode only Retry's HTTP 400 error field for the existing banner. Preserve raw model/key refusal envelopes and other submission-error handling. Verified on the accepted-main integration: 2424 full-suite tests, typecheck, full ESLint, production build and isolated synthetic production-browser Retry/Submit smoke. No real provider calls. --- components/session-core.ts | 15 +++++++++++---- tests/retry-failed-turn.test.ts | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/components/session-core.ts b/components/session-core.ts index c0ef90f..43c7339 100644 --- a/components/session-core.ts +++ b/components/session-core.ts @@ -715,10 +715,17 @@ export class SessionModel { throw new Error("A turn is already running — try again in a moment."); } if (!res.ok) { - // Refusal envelopes (503 BYOK/key gates) and 400s: the RAW body - // text rides the error message — the banner shows it, and the - // page's refusal flow JSON-parses the envelope out of it. - throw new Error((await res.text()) || `The send failed (${res.status}).`); + // Keep model/key refusal envelopes verbatim: the page's refusal + // flow parses them. Retry's 400s are ordinary user-facing + // guidance, so show their error text without JSON syntax. + let message = (await res.text()) || `The send failed (${res.status}).`; + if (input.type === "retry" && res.status === 400) { + try { + const refusal = JSON.parse(message) as { error?: unknown } | null; + if (typeof refusal?.error === "string" && refusal.error.length > 0) message = refusal.error; + } catch { /* Non-envelope failures keep their original text. */ } + } + throw new Error(message); } // CONNECTION DIET B (owner, 2026-10-10 — parallel-session freeze // postmortem): the POST acknowledges and closes; there is no stream diff --git a/tests/retry-failed-turn.test.ts b/tests/retry-failed-turn.test.ts index 38ba054..cdc364b 100644 --- a/tests/retry-failed-turn.test.ts +++ b/tests/retry-failed-turn.test.ts @@ -487,11 +487,27 @@ describe("the Retry button's client path", () => { }); await m.send({ type: "retry" }); assert.equal(m.getState().status, "error"); - assert.equal(JSON.parse(m.getState().error!.message).error, refused, "the existing refusal-envelope path carries both messages"); + assert.equal(m.getState().error!.message, refused, "the banner keeps both messages as readable text, without JSON syntax"); assert.deepEqual(m.getState().nativeMessages, compacted); assert.equal(endsInFailedTurn(m.getState().nativeMessages), false); }); + for (const { status, body, expected } of [ + { status: 400, body: '{"error":"retry input requires a failed turn to retry"}', expected: "retry input requires a failed turn to retry" }, + { status: 400, body: "Plain refusal", expected: "Plain refusal" }, + { status: 400, body: '{"reason":"not an error envelope"}', expected: '{"reason":"not an error envelope"}' }, + { status: 503, body: '{"error":"model gate","code":"models_not_configured"}', expected: '{"error":"model gate","code":"models_not_configured"}' }, + ]) { + it(`preserves the appropriate Retry refusal format for ${status}: ${body}`, async () => { + const m = new SessionModel({ + chatId: "chat_x", initialMessages: [newUserMessage("hello"), failed("failed-response")], + fetch: async () => new Response(body, { status }), + }); + await m.send({ type: "retry" }); + assert.equal(m.getState().error?.message, expected); + }); + } + it("after a dropped request, Retry posts straight from the error status", async () => { const failedTurn = [newUserMessage("hello"), failed("a1")]; const posted: string[] = []; From 1ff60b4681119b84035fd601609f340712345e06 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 18:39:42 +0200 Subject: [PATCH 42/87] fix: recover opening refusals after view teardown --- components/session-core.ts | 25 +++++-- tests/opening-delivery.test.ts | 124 ++++++++++++++++++++++++++++++++- 2 files changed, 142 insertions(+), 7 deletions(-) diff --git a/components/session-core.ts b/components/session-core.ts index 8c53aa4..4a3c19e 100644 --- a/components/session-core.ts +++ b/components/session-core.ts @@ -258,6 +258,9 @@ export class SessionModel { // The single live consume (POST response or reattach stream): a newer // consume cancels the older — one reader at a time. private consumeAbort: AbortController | null = null; + /** Retain the latest request across stop(): an opening POST still owns + * its refusal after view detachment, but never after a newer request. */ + private latestRequest: AbortController | null = null; /** Synchronous ready check for the gate (send/read mount logic). */ readyRef = true; @@ -555,6 +558,7 @@ export class SessionModel { this.consumeAbort?.abort(); const controller = new AbortController(); this.consumeAbort = controller; + this.latestRequest = controller; // REPLAY ONTO A CLEAN SLATE (owner, optimistic round 3, 2026-10-02): // the reattach stream replays the turn from its FIRST event — an // aborted earlier attempt (React StrictMode's simulated remount) @@ -645,6 +649,7 @@ export class SessionModel { this.consumeAbort?.abort(); const controller = new AbortController(); this.consumeAbort = controller; + this.latestRequest = controller; this.setError(null); this.setStatus("submitted"); if (recovering && input.type === "answer") { @@ -741,11 +746,21 @@ export class SessionModel { await this.resumeStream(true); return true; } catch (err) { - if (this.consumeAbort !== controller || controller.signal.aborted) return accepted; - // Restore the question, or the new-session opening draft, when - // acceptance failed/is unknown. Other text sends keep their existing - // optimistic presentation; remove only THIS attempt's local entries. - if (input.type === "answer" || (input.type === "text" && onAccepted && !accepted)) { + const failedOpening = input.type === "text" && onAccepted !== undefined && !accepted; + if (failedOpening) { + // The POST outlives stop(), including StrictMode's cleanup. Remove + // only its own failed optimistic bubble even if a newer request + // won; never touch canonical messages or another attempt's tail. + const ids = new Set(optimistic.map(m => m.id)); + this.mutate(() => { this.tail = this.tail.filter(m => !ids.has(m.id)); }); + } + const detachedOpening = failedOpening && this.consumeAbort === null && this.latestRequest === controller; + if ((this.consumeAbort !== controller || controller.signal.aborted) && !detachedOpening) return accepted; + // A detached opening refusal must still reach the replayed mount's + // existing setup recovery. A newer send/reattach ALWAYS owns errors + // and status instead, even if that request has since been stopped. + // Ordinary text and answer teardown behavior stays unchanged. + if (input.type === "answer") { const ids = new Set(optimistic.map(m => m.id)); this.mutate(() => { this.tail = this.tail.filter(m => !ids.has(m.id)); diff --git a/tests/opening-delivery.test.ts b/tests/opening-delivery.test.ts index 11926bc..35ef0d6 100644 --- a/tests/opening-delivery.test.ts +++ b/tests/opening-delivery.test.ts @@ -5,8 +5,9 @@ import { newUserMessage } from "../src/core/native-messages.ts"; function deferred() { let resolve!: (value: T) => void; - const promise = new Promise(done => { resolve = done; }); - return { promise, resolve }; + let reject!: (reason: unknown) => void; + const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); + return { promise, resolve, reject }; } describe("new-session opening delivery acceptance", () => { @@ -60,6 +61,125 @@ describe("new-session opening delivery acceptance", () => { }); } + for (const failure of ["network", "503"] as const) { + it(`a teardown racing an opening ${failure} failure restores the bubble/error recovery state`, async () => { + const post = deferred(); + let notified = 0; + const envelope = JSON.stringify({ code: "models_not_configured", missing: ["strong"] }); + const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => { + assert.equal(String(url), "/api/chat", "failure must not attach or resend"); + return post.promise; + } }); + const sending = m.send({ type: "text", text: "recover this opening" }, () => { notified++; }); + m.stop(); // StrictMode's cleanup detaches the consume, NOT this POST. + if (failure === "network") post.reject(new TypeError("opening connection lost")); + else post.resolve(new Response(envelope, { status: 503 })); + assert.equal(await sending, false); + assert.equal(notified, 0); + assert.equal(m.getState().nativeMessages.length, 0, "no stranded optimistic opening bubble"); + assert.equal(m.status, "error"); + assert.equal(m.getState().error?.message, failure === "network" ? "opening connection lost" : envelope, + "the raw refusal must reach the existing setup-recovery effect"); + }); + + it(`an older opening ${failure} failure removes only its own bubble while a newer send owns state`, async () => { + const first = deferred(); + const second = deferred(); + let posts = 0; + const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => { + assert.equal(String(url), "/api/chat"); + return ++posts === 1 ? first.promise : second.promise; + } }); + const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted")); + m.stop(); + const newer = m.send({ type: "text", text: "newer text" }); + if (failure === "network") first.reject(new TypeError("older lost acknowledgment")); + else first.resolve(new Response("older refusal", { status: 503 })); + assert.equal(await older, false); + assert.equal(m.status, "submitted", "older failure must not settle the newer send"); + assert.equal(m.getState().error, null); + assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["newer text"]); + second.resolve(new Response("newer failure", { status: 503 })); + assert.equal(await newer, false); + assert.equal(m.getState().error?.message, "newer failure"); + assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["newer text"], + "ordinary text retains its existing optimistic failure presentation"); + }); + } + + it("a newer send remains the owner even after it fails and is stopped", async () => { + const first = deferred(); + let posts = 0; + const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async () => { + return ++posts === 1 ? first.promise : new Response("newer failure", { status: 503 }); + } }); + const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted")); + m.stop(); + await m.send({ type: "text", text: "newer text" }); + m.stop(); + first.reject(new TypeError("older failure")); + assert.equal(await older, false); + assert.equal(m.status, "ready"); + assert.equal(m.getState().error?.message, "newer failure"); + assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["newer text"]); + }); + + it("a replacement reattach owns state instead of the older stopped opening POST", async () => { + const post = deferred(); + const record = deferred(); + const saved = newUserMessage("server-owned text"); + const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => { + if (String(url) === "/api/chat") return post.promise; + if (String(url).endsWith("/stream")) return new Response("data: [DONE]\n\n"); + return record.promise; + } }); + const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted")); + m.stop(); + const replacement = m.resumeStream(); + post.reject(new TypeError("older failure")); + assert.equal(await older, false); + assert.equal(m.status, "submitted"); + assert.equal(m.getState().error, null); + record.resolve(Response.json({ messages: [saved] })); + await replacement; + assert.equal(m.status, "ready"); + assert.deepEqual(m.getState().nativeMessages, [saved]); + assert.equal(m.getState().error, null); + }); + + it("a stopped replacement reattach still supersedes the older opening refusal", async () => { + const post = deferred(); + const record = deferred(); + const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => { + return String(url) === "/api/chat" ? post.promise : record.promise; + } }); + const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted")); + m.stop(); + const replacement = m.resumeStream(); + m.stop(); + post.resolve(new Response("older refusal", { status: 503 })); + assert.equal(await older, false); + assert.equal(m.status, "ready"); + assert.equal(m.getState().error, null); + assert.equal(m.getState().nativeMessages.length, 0); + record.resolve(Response.json({ messages: [] })); + await replacement; + assert.equal(m.status, "ready"); + assert.equal(m.getState().error, null); + }); + + it("a stopped ordinary text failure retains the pre-existing teardown behavior", async () => { + const post = deferred(); + const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async () => post.promise }); + const sending = m.send({ type: "text", text: "ordinary text" }); + m.stop(); + post.reject(new TypeError("connection lost")); + assert.equal(await sending, false); + assert.equal(m.status, "ready"); + assert.equal(m.getState().error, null); + assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["ordinary text"]); + }); + it("post-acceptance attachment failure remains accepted and does not resend input", async () => { let posts = 0; let notified = 0; From 3b29b5e9e75b26bf0e29bc1c2fb5a9a3bf5298c8 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 18:56:55 +0200 Subject: [PATCH 43/87] fix: retire stale composer recovery drafts before retry A failed-response Retry submits no composer text. Retire the earlier accepted text send's recovery fallback synchronously so a Retry model/key refusal cannot restore it over the current unsent draft. Never clear a later ordinary send's fallback at async settlement. Add key/model503, superseding-draft and ordinary-refusal coverage. Verified 183 focused tests, typecheck, full ESLint, production build, and a red/green live production regression plus ordinary-draft recovery; no external provider calls. --- components/chat.tsx | 14 ++++++++- tests/byok-refusal.test.ts | 63 +++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index 20ccc55..6b2933a 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -162,6 +162,18 @@ export function parseProviderKeyRefusal(message: string | undefined): boolean { } } +/** Retry sends no composer text. Its refusal must not resurrect the draft + * from an earlier, already-durable text send over today's unsent composer. + * Retire that fallback synchronously, never when the async Retry settles: + * a later ordinary send may own a new recovery draft by then. */ +export function retryFailedResponse( + send: (input: ChatInput) => void, + submittedDraft: { current: string | null }, +): void { + submittedDraft.current = null; + void send({ type: "retry" }); +} + /** useLayoutEffect that is silent during SSR (client components still render on the server in Next). */ const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; @@ -676,7 +688,7 @@ export function Chat({ // "error" too: a dropped stream leaves the model there, and send() // recovers from it. Later compaction requires new input instead. const retryable = (status === "ready" || status === "error") && !busy && !compacting && endsInFailedTurn(nativeMessages); - const retryFailedTurn = () => void send({ type: "retry" }); + const retryFailedTurn = () => retryFailedResponse(send, draftRef); const canSend = (text.trim().length > 0 || dictation.active) && !pending && !activeProblem && !compacting; diff --git a/tests/byok-refusal.test.ts b/tests/byok-refusal.test.ts index 7627b8f..f30daf6 100644 --- a/tests/byok-refusal.test.ts +++ b/tests/byok-refusal.test.ts @@ -1,6 +1,9 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { parseModelsRefusal, parseProviderKeyRefusal } from "../components/chat.tsx"; +import { parseModelsRefusal, parseProviderKeyRefusal, retryFailedResponse } from "../components/chat.tsx"; +import { SessionModel } from "../components/session-core.ts"; +import { fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { newUserMessage, type LhMessage } from "../src/core/native-messages.ts"; import { prefillFromSlots, prefillLevelFromPin, setupStage } from "../components/setup-dialog.tsx"; import { isModelsRefusal, @@ -39,6 +42,64 @@ describe("parseModelsRefusal — the send refusal detector", () => { }); }); +describe("Retry owns no composer recovery draft", () => { + for (const envelope of [providerKeyMissing("openrouter", ["strong"]), modelsNotConfigured(["strong"])]) { + it(`accepted A / failed model / unsent B / Retry ${envelope.code} retires only A's fallback`, async () => { + const durable: LhMessage[] = [ + newUserMessage("Accepted text A"), + { ...fauxAssistantMessage("Partial reply", { stopReason: "error", errorMessage: "Model failed" }), id: "failed-A" }, + ]; + const submittedDraft: { current: string | null } = { current: "Accepted text A" }; + let composer = "Unsent text B"; + const body = JSON.stringify(envelope); + const model = new SessionModel({ + chatId: "fixture", initialMessages: durable, + fetch: async (_url, init) => { + assert.equal(submittedDraft.current, null, "retire A before Retry can reach a refusal"); + assert.deepEqual(JSON.parse(String(init?.body)).input, { type: "retry" }); + return new Response(body, { status: 503 }); + }, + }); + let request!: Promise; + retryFailedResponse((input) => request = model.send(input), submittedDraft); + await request; + assert.equal(model.getState().error?.message, body, "503 stays raw for existing setup/picker routing"); + assert.ok(parseModelsRefusal(body) || parseProviderKeyRefusal(body)); + // The existing refusal effect restores only an owned submitted + // draft. Retry must leave this slot empty, not replace B with A. + if (submittedDraft.current !== null) composer = submittedDraft.current; + assert.equal(composer, "Unsent text B"); + assert.deepEqual(model.getState().nativeMessages, durable, "Retry adds no optimistic learner message"); + }); + } + + it("does not clear a later ordinary send's recovery draft when Retry settles", async () => { + const submittedDraft: { current: string | null } = { current: "Accepted text A" }; + let finish!: () => void; + const pending = new Promise((resolve) => { finish = resolve; }); + retryFailedResponse(async () => pending, submittedDraft); + assert.equal(submittedDraft.current, null); + submittedDraft.current = "Later ordinary text C"; + finish(); + await pending; + await Promise.resolve(); + assert.equal(submittedDraft.current, "Later ordinary text C", "no asynchronous cleanup may retire a newer owner"); + }); + + it("ordinary text refusals still keep their submitted draft for recovery", async () => { + const submittedDraft: { current: string | null } = { current: "Ordinary text C" }; + const body = JSON.stringify(providerKeyMissing("openrouter", ["strong"])); + const model = new SessionModel({ + chatId: "fixture", initialMessages: [], + fetch: async () => new Response(body, { status: 503 }), + }); + await model.send({ type: "text", text: submittedDraft.current! }); + assert.equal(submittedDraft.current, "Ordinary text C"); + assert.equal(model.getState().error?.message, body); + assert.equal(parseProviderKeyRefusal(model.getState().error?.message), true); + }); +}); + describe("isModelsRefusal — the job-record matcher", () => { it("matches the runner's composed wording, not real failures", () => { assert.equal(isModelsRefusal(`${modelsNotConfigured(["vision"]).error} (missing: vision)`), true); From 3b97056d014c3d8b530810268a931c5f0b30a62c Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 19:17:27 +0200 Subject: [PATCH 44/87] test: infer the session send return type in refusal coverage Avoid hardcoding Promise so the test also typechecks with PR154's Promise admission result. Type-only correction; no product behavior changes. --- tests/byok-refusal.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/byok-refusal.test.ts b/tests/byok-refusal.test.ts index f30daf6..f942d68 100644 --- a/tests/byok-refusal.test.ts +++ b/tests/byok-refusal.test.ts @@ -60,7 +60,7 @@ describe("Retry owns no composer recovery draft", () => { return new Response(body, { status: 503 }); }, }); - let request!: Promise; + let request!: ReturnType; retryFailedResponse((input) => request = model.send(input), submittedDraft); await request; assert.equal(model.getState().error?.message, body, "503 stays raw for existing setup/picker routing"); From f01fdf3260a31091b96f81e6f5f31b14b34b2d3a Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 20:49:04 +0200 Subject: [PATCH 45/87] fix: require a successful probe result before handoff Adapt the bounded probe-completion foundation from PR #130 by aksalatdev (source commit 535202bebe038fa1e0648f5df809d5ccfc26d071), not its mixed stream-salvage implementation. Require a non-failed call followed by a matching probe_done result with neither isError nor explicit details.ok:false. Preserve older clean acknowledgments, saved attempts, and all existing handoff/retry/provider policies. Share the native completion seam with phase detection and exclude explicit refusals from UI phases. Verify retained failed-probe Retry recovery with real agent/tool composition and synthetic engines: one fresh conclusion, one handoff, then teacher selection, without stored-tool replay. Focused tests 58/58; full tests 2473/2473; typecheck and full ESLint pass in isolated storage with scrubbed provider credentials. Co-authored-by: aksalatdev <73862605+aksalatdev@users.noreply.github.com> --- docs/probe-teach-split.md | 11 ++++-- src/agent/tools/probe.ts | 5 +-- src/core/native-messages.ts | 34 ++++++++++++++---- src/core/phases.ts | 19 +++++----- tests/phases.test.ts | 33 +++++++++++++---- tests/probe-done.test.ts | 63 +++++++++++++++++++++++++++++++-- tests/retry-failed-turn.test.ts | 45 ++++++++++++++++++++++- 7 files changed, 178 insertions(+), 32 deletions(-) diff --git a/docs/probe-teach-split.md b/docs/probe-teach-split.md index d1b1c96..7f54530 100644 --- a/docs/probe-teach-split.md +++ b/docs/probe-teach-split.md @@ -21,7 +21,8 @@ philosophy so they share a view of the whole process). field it must not use. No plan tools, no jobs, no demos. - **The teacher** runs everything from planning onward: full tool set (teach quiz with nodeId, plan_init, jobs, demos). Constructed the moment the - transcript carries a `probe_done`. + transcript carries a concluded `probe_done`: a non-failed/non-aborted + assistant call followed by a matching successful tool result. - **The swap happens between probing and planning** — the planning phase needs full knowledge of how teaching is done, so the teacher agent starts at planning. @@ -43,6 +44,12 @@ The prober concludes explicitly: - **Refuses while a question is open** ("resolve the open question first") — same doctrine as nodeId validation. - **Refuses a second conclusion** (idempotent: the probe concludes once). + Conclusion requires a later `probe_done` result matching the call's ID + and tool name. The calling generation must not have failed or aborted; + the result must not be `isError` or explicitly contain `details.ok:false`. + Clean acknowledgments without an explicit `ok:true` remain valid. Bare, + unmatched, errored, or refused calls keep probing open; saved attempts + are never deleted or replay-executed by this detection rule. - The assessment is the tool call's input, so it streams to the learner live and is durable in the transcript by the tool result itself. A dedicated assessment card/viewer comes later (UI). @@ -53,7 +60,7 @@ The prober concludes explicitly: - **Side effect: it queues the handoff.** When the prober's turn ends, the wake pipeline (the same machinery that delivers sub-agent results and abort steers) fires a server-side turn with a handoff steer — the teacher - is constructed (probe_done is in the transcript) and authors the plan. + is constructed (probe_done has successfully concluded) and authors the plan. The learner experiences assessment → plan as one continuous flow. If the wake is lost (crash window), the next client message still constructs the teacher — the transcript is the source of truth. diff --git a/src/agent/tools/probe.ts b/src/agent/tools/probe.ts index 11e3cd4..7cd9b84 100644 --- a/src/agent/tools/probe.ts +++ b/src/agent/tools/probe.ts @@ -42,8 +42,9 @@ function probeConcluded(messages: readonly LhMessage[]): boolean { * live), so the agent sees the fixed PROBE_DONE_ACK_TEXT — never an * echo. details = {ok:true} only. * - * The first probe_done in the transcript is also the construction seam: - * from that message on, every request builds the TEACHER agent. + * The first concluded probe_done (non-failed call plus matching successful + * result) is also the construction seam: from that result on, every request + * builds the TEACHER agent. Bare calls and refused results keep probing. */ export function probeDoneTool(ctx: ProbeDoneContext): Record> { return { diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index 60f51ce..c9d5a46 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -669,15 +669,35 @@ export function danglingQuestionOf( return null; } -/** The probe→teach construction seam (chat-handler): a probe_done call in - * the transcript — any state — means the probe is concluded: teach. */ -export function hasProbeDone(messages: readonly LhMessage[]): boolean { +/** Successful probe_done result MESSAGE ids, in transcript order. A call + * from a failed/aborted generation grants no completion authority. Its + * later result must match both call id and tool name, must not be an + * error, and must not explicitly refuse with details.ok:false. Older + * clean acknowledgments without ok:true still count. No history changes. */ +export function probeDoneResultIds(messages: readonly LhMessage[]): Set { + const calls = new Set(); + const results = new Set(); for (const m of messages) { - if ((m as { role?: string }).role !== "assistant") continue; - const assistant = m as AssistantMessage; - if (assistant.content.some((b) => b.type === "toolCall" && b.name === "probe_done")) return true; + if (m.role === "assistant") { + if (m.stopReason === "error" || m.stopReason === "aborted") continue; + for (const block of m.content) { + if (block.type === "toolCall" && block.name === "probe_done") calls.add(block.id); + } + } else if ( + m.role === "toolResult" && m.toolName === "probe_done" && + calls.has(m.toolCallId) && !m.isError && + (m.details as { ok?: unknown } | null | undefined)?.ok !== false + ) { + results.add(m.id); + } } - return false; + return results; +} + +/** The probe→teach construction seam (chat-handler and the tool guard): + * only a concluded call plus a matching successful result means teach. */ +export function hasProbeDone(messages: readonly LhMessage[]): boolean { + return probeDoneResultIds(messages).size > 0; } /** Every toolResult id in the record — answer-event validation (the call diff --git a/src/core/phases.ts b/src/core/phases.ts index 4a6e0f4..0ecd870 100644 --- a/src/core/phases.ts +++ b/src/core/phases.ts @@ -33,7 +33,7 @@ * Both are strictly sequential; the two derivations agree on every settled * record (a call's part projects into the same view boundary). * - * Pure: no imports, no runtime deps — safe on server, client, and tests. + * Pure: only browser-safe core helpers — safe on server, client, and tests. */ /** The three phases of a session, as the picker / header / dividers see them. */ @@ -80,7 +80,10 @@ function advance( // plan → teach only via the first nodeId-tagged quiz CALL (the call, // not its answer — an errored-and-retried call still proves teaching // began, and the retry lands in the same group anyway). - if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") { + if ( + phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available" && + (part.output as { ok?: unknown } | null | undefined)?.ok !== false + ) { phase = "plan"; } // plan → teach via the first nodeId-tagged quizzing CALL — quiz OR @@ -153,7 +156,7 @@ export function phaseTimeline(messages: readonly PhaseMessage[]): Phase3[] { // ─── the native fold (Wave D: record-side phase over LhMessage[]) ─────────── -import type { LhMessage } from "./native-messages.ts"; +import { probeDoneResultIds, type LhMessage } from "./native-messages.ts"; /** The phase at `upToIndex` (inclusive; default: the last message) of a * NATIVE record. probe→plan advances at the probe_done call's clean @@ -163,18 +166,12 @@ import type { LhMessage } from "./native-messages.ts"; export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number): Phase3 { let phase: Phase3 = "probe"; const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1); + const concluded = probeDoneResultIds(messages.slice(0, last + 1)); for (let i = 0; i <= last; i++) { const m = messages[i]!; const role = (m as { role?: string }).role; if (role === "toolResult") { - const result = m as { toolName?: string; isError?: boolean }; - if ( - phase === "probe" && - result.toolName === "probe_done" && - !result.isError - ) { - phase = "plan"; - } + if (phase === "probe" && concluded.has(m.id)) phase = "plan"; } else if (role === "assistant") { const content = (m as { content?: ReadonlyArray<{ type?: string; name?: string; arguments?: unknown }> }).content ?? []; if (phase === "plan") { diff --git a/tests/phases.test.ts b/tests/phases.test.ts index ad03da0..78ed427 100644 --- a/tests/phases.test.ts +++ b/tests/phases.test.ts @@ -103,6 +103,16 @@ test("phaseAt3: a mere probe_done CALL (input-available, not yet concluded) keep assert.equal(still, "probe"); }); +test("phaseAt3 and phaseTimeline: explicit probe refusals never advance the UI phase", () => { + const refused = assistantTool("probe_done", "output-available", { ok: false, error: "question open" }); + const errored = assistantTool("probe_done", "output-error", { ok: true }); + const tagged = assistantQuiz({ nodeId: "n1" }); + const clean = assistantTool("probe_done", "output-available", { ok: true }); + const messages = [refused, errored, tagged, clean, tagged]; + assert.equal(phaseAt3(messages, 2), "probe"); + assert.deepEqual(phaseTimeline(messages), ["probe", "probe", "probe", "plan", "teach"]); +}); + test("phaseAt3: legacy transcripts without probe_done read as probe (no backwards compat — data wiped)", () => { const legacy = phaseAt3([user("teach me lacan"), assistantTool("plan_init", "output-available", PLAN_OK)]); assert.equal(legacy, "probe", "plan_init alone never flips a phase post-split"); @@ -158,23 +168,31 @@ function assistantQuizCall(arguments_: Record): LhMessage { } as unknown as LhMessage; } +function assistantProbeCall(): LhMessage { + return { + ...assistantQuizCall({}), + content: [{ type: "toolCall", id: "call_probe_done", name: "probe_done", arguments: {} }], + } as LhMessage; +} + test("phaseAt3Lh (native fold): probe→plan at the clean probe_done result; plan→teach at the first tagged quiz CALL", () => { const done = toolResult("probe_done"); + const call = assistantProbeCall(); const tagged = assistantQuizCall({ question: "q", nodeId: "gravity-basics", correctAnswer: "a" }); const untagged = assistantQuizCall({ question: "q", correctAnswer: "a" }); - assert.equal(phaseAt3Lh([done, untagged]), "plan", "an untagged quiz call never starts teaching"); - assert.equal(phaseAt3Lh([done, tagged]), "teach", "the tagged quiz CALL is the seam"); + assert.equal(phaseAt3Lh([call, done, untagged]), "plan", "an untagged quiz call never starts teaching"); + assert.equal(phaseAt3Lh([call, done, tagged]), "teach", "the tagged quiz CALL is the seam"); assert.equal(phaseAt3Lh([tagged]), "probe", "the chain never skips (tag before probe_done)"); assert.equal( - phaseAt3Lh([toolResult("probe_done", true), tagged]), + phaseAt3Lh([call, toolResult("probe_done", true), tagged]), "probe", "an ERRORED probe_done concludes nothing", ); // The two folds agree on the same settled transcript (the view projection // of the tagged call carries its arguments as the part input). assert.equal( - phaseAt3Lh([done, tagged]), + phaseAt3Lh([call, done, tagged]), phaseAt3([ { parts: [{ type: "tool-probe_done", state: "output-available" } as never] }, { parts: [{ type: "tool-quiz", input: { nodeId: "gravity-basics" } } as never] }, @@ -188,6 +206,7 @@ test("phaseAt3Lh (native fold): probe→plan at the clean probe_done result; pla test("phaseAt3 + phaseAt3Lh: a tagged explain CALL is the plan→teach seam too", () => { const done = toolResult("probe_done"); + const call = assistantProbeCall(); const explainCall = (args: Record): LhMessage => ({ id: "a_explain", @@ -199,10 +218,10 @@ test("phaseAt3 + phaseAt3Lh: a tagged explain CALL is the plan→teach seam too" const tagged = explainCall({ question: "q", correctAnswer: "a", explanation: "e", nodeId: "gravity-basics" }); const untagged = explainCall({ question: "q", correctAnswer: "a", explanation: "e" }); - assert.equal(phaseAt3Lh([done, untagged]), "plan", "an untagged explain never starts teaching"); - assert.equal(phaseAt3Lh([done, tagged]), "teach", "the tagged explain CALL is the seam"); + assert.equal(phaseAt3Lh([call, done, untagged]), "plan", "an untagged explain never starts teaching"); + assert.equal(phaseAt3Lh([call, done, tagged]), "teach", "the tagged explain CALL is the seam"); assert.equal( - phaseAt3Lh([done, tagged]), + phaseAt3Lh([call, done, tagged]), phaseAt3([ { parts: [{ type: "tool-probe_done", state: "output-available" } as never] }, { parts: [{ type: "tool-explain", input: { nodeId: "gravity-basics" } } as never] }, diff --git a/tests/probe-done.test.ts b/tests/probe-done.test.ts index c880e18..0d7d5fe 100644 --- a/tests/probe-done.test.ts +++ b/tests/probe-done.test.ts @@ -13,12 +13,13 @@ import * as path from "node:path"; import { after, describe, it } from "node:test"; import { probeDoneTool } from "../src/agent/tools/probe.ts"; import type { SteerMessage } from "../src/core/contracts.ts"; -import type { LhMessage } from "../src/core/native-messages.ts"; +import { hasProbeDone, type LhMessage } from "../src/core/native-messages.ts"; +import { phaseAt3Lh } from "../src/core/phases.ts"; import { HANDOFF_TEXT } from "../src/core/contracts.ts"; import { chatStore } from "../src/store/chats.ts"; import { PATHS } from "../config.ts"; -const dataDir = path.join(PATHS.root, "data"); +const dataDir = PATHS.data; fs.mkdirSync(dataDir, { recursive: true }); const createdChats: string[] = []; @@ -142,3 +143,61 @@ describe("probe_done: the probe→planning seam", () => { assert.equal(res.ok, true, "ok:true even when the queue is lost — the next client message constructs the teacher off the transcript seam"); }); }); + +/** A real record result, distinct from the calling assistant message. */ +function probeResult(overrides: Partial> = {}): LhMessage { + return { + id: "probe-result", role: "toolResult", toolCallId: "call_probe", toolName: "probe_done", + content: [{ type: "text", text: "ok" }], isError: false, timestamp: 1, + ...overrides, + }; +} + +const probeCall = () => assistantWithCall("probe", "probe_done", { assessment: ASSESSMENT }); +const failedProbeCall = (stopReason: "error" | "aborted"): LhMessage => ({ ...probeCall(), stopReason } as LhMessage); +const incompleteProbes: Array<[string, () => LhMessage[]]> = [ + ["unmatched call", () => [probeCall()]], + ["errored result", () => [probeCall(), probeResult({ isError: true })]], + ["explicit refusal without isError", () => [probeCall(), probeResult({ details: { ok: false, error: "refused" } })]], + ["result without a call", () => [probeResult()]], + ["wrong call id", () => [probeCall(), probeResult({ toolCallId: "other" })]], + ["wrong result tool name", () => [probeCall(), probeResult({ toolName: "plan_init" })]], + ["wrong call tool name", () => [assistantWithCall("probe", "plan_init", {}), probeResult()]], + ["result before the call", () => [probeResult(), probeCall()]], + ["failed calling generation", () => [failedProbeCall("error"), probeResult({ details: { ok: true } })]], + ["aborted calling generation", () => [failedProbeCall("aborted"), probeResult({ details: { ok: true } })]], +]; + +describe("probe completion: matched successful results only (PR130 foundation)", () => { + for (const [name, messages] of incompleteProbes) { + it(`${name} keeps probing open and allows a fresh conclusion`, async () => { + const chatId = newChat(`Probe recovery: ${name}`); + const before = messages(); + const snapshot = structuredClone(before); + chatStore.saveMessages(chatId, before); + assert.equal(hasProbeDone(before), false); + assert.equal(phaseAt3Lh(before), "probe"); + const delivered: SteerMessage[] = []; + const tools = probeDoneTool({ chatId, chatStore, deliver: (s) => delivered.push(s) }); + assert.equal((await execute(tools, { assessment: ASSESSMENT })).ok, true); + assert.equal(delivered.length, 1, "only the fresh invocation queues a handoff"); + assert.deepEqual(before, snapshot, "the scans never mutate history"); + assert.deepEqual(chatStore.getChat(chatId)!.messages, snapshot, "the tool does not delete or replay saved attempts"); + }); + } + + for (const [name, result] of [ + ["current acknowledgment", probeResult({ details: { ok: true } })], + ["older clean acknowledgment without details", probeResult()], + ["older clean acknowledgment without ok", probeResult({ details: { assessment: ASSESSMENT } })], + ] as const) { + it(`${name} concludes exactly at the matching result`, () => { + const messages = [probeCall(), result]; + const before = structuredClone(messages); + assert.equal(hasProbeDone(messages), true); + assert.equal(phaseAt3Lh(messages, 0), "probe", "a future result cannot conclude an earlier prefix"); + assert.equal(phaseAt3Lh(messages, 1), "plan"); + assert.deepEqual(messages, before); + }); + } +}); diff --git a/tests/retry-failed-turn.test.ts b/tests/retry-failed-turn.test.ts index cdc364b..01edeb5 100644 --- a/tests/retry-failed-turn.test.ts +++ b/tests/retry-failed-turn.test.ts @@ -17,6 +17,7 @@ import type { AgentBuildOptions, TeachingAgent } from "../src/agent/teacher-agen import type { PiEngine } from "../src/agent/models.ts"; import type { LhMessage } from "../src/core/native-messages.ts"; import type { QuestionData } from "../src/core/question-core.ts"; +import type { SteerMessage } from "../src/core/contracts.ts"; // Set the storage/auth seams BEFORE any app/config-loading imports. assert.equal(process.env.NODE_ENV, "test", "Run with NODE_ENV=test; these tests must not invoke real providers."); @@ -36,7 +37,8 @@ const { jobStore } = await import("../src/store/jobs.ts"); const { subscribe } = await import("../src/core/events.ts"); const { buildAnswer, normalizeOptions, resolveCorrect } = await import("../src/core/question-core.ts"); const { ChatInputError, endsInFailedTurn, failedResponseAtEnd, inputAppends, parseChatInput, retryDrops } = await import("../src/core/input-events.ts"); -const { INTERRUPTED_TEXT, asAgentMessages, newHandoffMessage, newPlanBlockMessage, newUserMessage, reconstructRequestCause, toLlmMessages } = await import("../src/core/native-messages.ts"); +const { INTERRUPTED_TEXT, asAgentMessages, hasProbeDone, newHandoffMessage, newPlanBlockMessage, newUserMessage, reconstructRequestCause, toLlmMessages } = await import("../src/core/native-messages.ts"); +const { phaseAt3Lh } = await import("../src/core/phases.ts"); const { newCompactionMarker } = await import("../src/core/compaction.ts"); after(() => { @@ -287,6 +289,47 @@ describe("failed-response Retry (issue #110)", () => { assert.deepEqual(contexts.at(-1), ["user"]); }); + it("a retained failed probe retries with the prober, concludes once, then selects the teacher", async () => { + const input = { assessment: { strands: [{ strand: "planet order", assessment: "Names known; ordering needs work." }] } }; + const { engine, histories } = scripted([ + () => fauxAssistantMessage([fauxToolCall("probe_done", input, { id: "failed-probe" })], { stopReason: "error", errorMessage: "cut off" }), + () => fauxAssistantMessage([fauxToolCall("probe_done", input, { id: "fresh-probe" })], { stopReason: "toolUse" }), + () => fauxAssistantMessage("Assessment ready."), + () => fauxAssistantMessage("Now planning the lesson."), + ]); + const chat = createConfiguredChat("Test: failed probe recovery"); + const delivered: SteerMessage[] = []; + const selections: boolean[] = []; + const factory: AgentFactory = (opts) => { + selections.push(opts.teaching); + if (opts.teaching) return createTeacherAgent(undefined, { ...opts, engine }); + return createProberAgent({ + chatId: chat.id, chatStore, jobStore, deliver: (s) => delivered.push(s), spawn: () => {}, + }, { ...opts, engine }); + }; + await turn(engine, chat.id, { type: "text", text: "teach me planet order" }, factory); + const before = chatStore.getChat(chat.id)!.messages; + assert.ok(endsInFailedTurn(before)); + assert.equal(hasProbeDone(before), false); + assert.equal(phaseAt3Lh(before), "probe"); + assert.equal(delivered.length, 0, "the stored failed call never ran"); + + assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200); + const recovered = chatStore.getChat(chat.id)!.messages; + assertPreserved(before, recovered); + assert.deepEqual(selections, [false, false], "Retry must still build the prober"); + assert.equal(hasProbeDone(recovered), true); + assert.equal(phaseAt3Lh(recovered), "plan"); + assert.equal(delivered.length, 1, "the full retained-store guard accepts the fresh conclusion"); + assert.deepEqual(recovered.filter((m) => m.role === "toolResult").map((m) => m.role === "toolResult" && m.toolCallId), ["fresh-probe"]); + assert.doesNotMatch(histories[1]!, /failed-probe/, "the failed call is omitted only from temporary Retry history"); + + assert.equal((await turn(engine, chat.id, { type: "text", text: "go on" }, factory)).status, 200); + assert.deepEqual(selections, [false, false, true]); + assert.equal(delivered.length, 1, "later turns do not replay either saved call"); + assertPreserved(recovered, chatStore.getChat(chat.id)!.messages); + }); + for (const kind of ["prober", "teacher", "practice"] as const) { it(`uses the same retained-history retry boundary for the ${kind}`, async () => { const { engine, contexts } = scripted([() => fauxAssistantMessage("Recovered response.")]); From fbafc5e53a565d61e4eca57afc3cea59a50210b1 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 21:51:09 +0200 Subject: [PATCH 46/87] Finish Deepgram language settings with shared searchable picker --- README.md | 2 + components/dictation-language.tsx | 87 ++++++------------- components/dictation-preference.ts | 5 +- components/dictation.tsx | 24 +++--- components/settings/keys-section.tsx | 5 ++ components/settings/model-menu.tsx | 105 ++++++----------------- components/settings/search-menu-list.tsx | 93 ++++++++++++++++++++ docs/development.md | 2 +- tests/dictation-flow.test.tsx | 21 ++++- tests/dictation-language.test.ts | 9 +- tests/dictation-picker.test.tsx | 92 +++++++++++++++++--- tests/dictation-preference.test.ts | 17 ++++ 12 files changed, 292 insertions(+), 170 deletions(-) create mode 100644 components/settings/search-menu-list.tsx diff --git a/README.md b/README.md index a9ff694..0aa7706 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ Add your provider API key and choose your models, then start a session with what All three platforms support in-app updates. +**Dictation:** Create a [Deepgram API key](https://console.deepgram.com/) with the **Member role or higher** ([required for token minting](https://developers.deepgram.com/reference/auth/tokens/grant)) and add it under **Settings → Providers → Dictation**. Choose the language below the key; **Multilingual** supports automatic recognition and mixed-language speech. A `403 Insufficient permissions` error during token minting can indicate an insufficient key role. + ## Your keys, your data - **Your keys and models.** No LearnHarness subscription or markup. You pay your providers for usage; a live cost meter shows model costs. diff --git a/components/dictation-language.tsx b/components/dictation-language.tsx index ccb85cd..5f6528b 100644 --- a/components/dictation-language.tsx +++ b/components/dictation-language.tsx @@ -1,16 +1,13 @@ "use client"; import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; -import { Command } from "cmdk"; -import { CheckIcon, ChevronDownIcon } from "lucide-react"; -import { - DICTATION_LANGUAGES, fuzzyScore, type DictationLanguage, -} from "@/src/core"; +import { ChevronDownIcon } from "lucide-react"; +import { DICTATION_LANGUAGES, type DictationLanguage } from "@/src/core"; import { MenuShell } from "@/components/menu-shell"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Hint } from "@/components/ui/hint"; import { SettingsRow } from "@/components/settings/controls"; +import { SearchMenuList } from "@/components/settings/search-menu-list"; import { useDictationActive } from "@/components/dictation-state"; import { useDictationLanguage } from "@/components/dictation-preference"; @@ -30,16 +27,8 @@ function DictationLanguagePicker({ preference, disabled }: { }) { const { language, saving, error, load, save } = preference; const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); const triggerRef = useRef(null); const label = DICTATION_LANGUAGES.find((item) => item.code === language)?.label ?? "—"; - // Own ordering so clearing a search restores the catalog's order. - // cmdk handles keyboard/ARIA, but its DOM sorting retains old ranks. - const results = query.trim() ? DICTATION_LANGUAGES - .map((item) => ({ item, score: fuzzyScore(query, item.code, item.label) })) - .filter((item) => item.score !== null) - .sort((a, b) => b.score! - a.score!) - .map(({ item }) => item) : DICTATION_LANGUAGES; useEffect(() => { if (disabled) setOpen(false); }, [disabled]); useEffect(() => { @@ -50,9 +39,7 @@ function DictationLanguagePicker({ preference, disabled }: { if (!triggerRef.current?.contains(target)) setOpen(false); }; document.addEventListener("pointerdown", onPointerDown); - return () => { - document.removeEventListener("pointerdown", onPointerDown); - }; + return () => document.removeEventListener("pointerdown", onPointerDown); }, [open]); const closeOnEscape = (event: ReactKeyboardEvent) => { if (event.key !== "Escape") return; @@ -79,13 +66,11 @@ function DictationLanguagePicker({ preference, disabled }: { data-testid="dictation-language" aria-label={`Dictation language: ${label}`} aria-expanded={open && !disabled} - aria-haspopup="listbox" disabled={disabled || saving} onKeyDown={(event) => { if (open) closeOnEscape(event); }} onClick={() => { - setQuery(""); setOpen((value) => !value); - if (language === undefined || error) void load().catch(() => {}); + if (language === undefined) void load().catch(() => {}); }} className="h-8 min-w-0 max-w-[65%] px-2.5 font-normal" > @@ -97,47 +82,29 @@ function DictationLanguagePicker({ preference, disabled }: { anchorRef={triggerRef} testid="dictation-language-menu" preferredSide="down" - menuClass="flex w-72 max-w-[calc(100vw-1rem)] flex-col rounded-xl border bg-card p-2 text-xs shadow-xl" + menuClass="flex w-72 max-w-[calc(100vw-1rem)] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl" > - {error &&
{error}
} - { - closeOnEscape(event); - // Keep menu navigation out of the composer's submit/shortcut - // handlers. cmdk owns selection, scrolling and ARIA state. - if (["ArrowDown", "ArrowUp", "Enter", "Home", "End"].includes(event.key)) event.stopPropagation(); - }} - > - - - - - No languages match. - {results.map((item) => { void pick(item.code); }} - asChild - > - - )} - - +
{ + closeOnEscape(event); + if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); } + }}> + {error &&
{error}
} + item.code} + searchTerms={(item) => [item.code, item.label]} + isSelected={(item) => item.code === language} + onPick={(item) => { void pick(item.code); }} + disabled={saving || language === undefined} + searchTestid="dictation-language-search" + listTestid="dictation-language-list" + optionTestid={(item) => `dictation-language-${item.code}`} + ariaLabel="Search dictation languages" + placeholder="Search languages…" + empty="No languages match." + renderItem={(item) => {item.label}} + /> +
} ; } diff --git a/components/dictation-preference.ts b/components/dictation-preference.ts index 8151a9b..38b922a 100644 --- a/components/dictation-preference.ts +++ b/components/dictation-preference.ts @@ -27,6 +27,7 @@ export function createDictationLanguageStore(request: typeof fetch, saved: () => function load(refresh = false): Promise { if (writing) return getLanguage(); if (reading && !refresh) return reading; + if (!refresh && !needsRead && snapshot.language !== undefined) return Promise.resolve(snapshot.language); const version = ++revision; needsRead = true; reading = Promise.resolve().then(async () => { @@ -118,7 +119,9 @@ function subscribe(listener: () => void) { stopSignals = onSettingsSaved((section) => { if (section === "dictation" && !announcingSave) void store.load(true).catch(() => {}); }); - if (!store.getSnapshot().saving) void store.load(true).catch(() => {}); + // A confirmed preference lives for the window, not the page. Navigation + // and reopening Settings must not add a new network failure point. + void store.getLanguage().catch(() => {}); } return () => { off(); if (--readers === 0) { stopSignals?.(); stopSignals = undefined; } }; } diff --git a/components/dictation.tsx b/components/dictation.tsx index fdb78c7..96c6cbd 100644 --- a/components/dictation.tsx +++ b/components/dictation.tsx @@ -4,7 +4,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Loader2Icon, MicIcon } from "lucide-react"; import { useSettingsDialog } from "@/components/settings/settings-dialog"; import { Hint } from "@/components/ui/hint"; -import { Button } from "@/components/ui/button"; import { deepgramListenUrl, type DictationLanguage } from "@/src/core"; import { useDictationLanguage } from "@/components/dictation-preference"; import { setDictationActive } from "@/components/dictation-state"; @@ -676,11 +675,14 @@ export function DictationBar({ dictation }: { dictation: Dictation }) { if (!active) { return ( - - + ); } @@ -703,19 +705,17 @@ export function DictationBar({ dictation }: { dictation: Dictation }) { return (
- +
diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index f7c3f4a..14680b7 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -30,6 +30,7 @@ import { SubscriptionConnections } from "./subscription-connections.tsx"; import { ProviderMenuList } from "./model-menu.tsx"; import { MenuShell } from "@/components/menu-shell"; import { notifySettingsSaved } from "@/components/settings-signal"; +import { DictationLanguageSetting } from "@/components/dictation-language"; /** One pi-ai provider with plain API-key auth (the Platform seam — * derived from pi-ai's own env-key table; no drift possible). */ @@ -350,6 +351,9 @@ export function KeysSection({ if (openRow === null && adding === null) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key !== "Escape") return; + // A language dropdown above this editor in the dismissal stack owns + // the first Escape; do not swallow it at document capture phase. + if (document.querySelector('[data-testid="dictation-language-menu"]')) return; e.preventDefault(); e.stopPropagation(); setOpenRow(null); @@ -486,6 +490,7 @@ export function KeysSection({ testid={`key-${status.id.toLowerCase()}`} /> ))} + {label === "Dictation" && } )} diff --git a/components/settings/model-menu.tsx b/components/settings/model-menu.tsx index 19697b9..792ae23 100644 --- a/components/settings/model-menu.tsx +++ b/components/settings/model-menu.tsx @@ -23,6 +23,7 @@ import { useEffect, useRef, useState } from "react"; import type { ModelCatalogEntry } from "@/src/core"; import { fuzzyScore, subscriptionLabel } from "@/src/core"; import { formatTokens } from "@/components/usage-format"; +import { SearchMenuList } from "./search-menu-list"; function priceLabel(cost: ModelCatalogEntry["cost"]): string { const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`; @@ -53,90 +54,34 @@ export function ModelMenuList({ /** Quiet footer note under the list (context-specific). */ footer?: string; }) { - const [query, setQuery] = useState(""); - const [menuIdx, setMenuIdx] = useState(null); - const searchRef = useRef(null); - const listRef = useRef(null); - - useEffect(() => { - searchRef.current?.focus(); - }, []); - - // Keep the arrow-walked result visible in the scrolling list. - useEffect(() => { - if (menuIdx === null) return; - listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" }); - }, [menuIdx]); - - // The filtered, ranked model list. - const results = (models ?? []) - .map((m) => ({ m, s: fuzzyScore(query, m.provider, m.id, m.name) })) - .filter((r) => r.s !== null) - .sort((a, b) => b.s! - a.s! || a.m.id.localeCompare(b.m.id)) - .map((r) => r.m); - return ( <> - { - setQuery(e.target.value); - setMenuIdx(null); // new query → the walk restarts on Enter/↓ - }} - onKeyDown={(e) => { - if (e.key === "ArrowDown" || e.key === "ArrowUp") { - e.preventDefault(); - e.stopPropagation(); - if (results.length === 0) return; - setMenuIdx((i) => - e.key === "ArrowDown" - ? Math.min((i ?? -1) + 1, results.length - 1) - : Math.max((i ?? results.length) - 1, 0), - ); - } else if (e.key === "Enter") { - const chosen = results[menuIdx ?? 0]; - if (chosen) { - e.preventDefault(); - e.stopPropagation(); - onPick(chosen); - } - } - }} + `${m.provider}/${m.id}`} + searchTerms={(m) => [m.provider, m.id, m.name]} + compare={(a, b) => a.id.localeCompare(b.id)} + isSelected={(m) => m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider)} + onPick={onPick} + searchTestid="model-search" + listTestid="model-list" + optionTestid={(m) => `model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`} placeholder="Search models…" - className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring" + loading="Loading models…" + empty="No models match." + renderItem={(m) => <> + + {m.name} + + {subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)} + + + + {subscriptionLabel(`${m.provider}:${m.id}`) ? `${subscriptionLabel(`${m.provider}:${m.id}`)} (subscription)` : m.provider} · {m.id} + {formatTokens(m.contextWindow)} + + } /> - {!models &&
Loading models…
} - {models && results.length === 0 && ( -
No models match.
- )} -
    - {results.map((m, i) => ( -
  • - -
  • - ))} -
{footer && (
{footer}
)} diff --git a/components/settings/search-menu-list.tsx b/components/settings/search-menu-list.tsx new file mode 100644 index 0000000..08a4fa6 --- /dev/null +++ b/components/settings/search-menu-list.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { fuzzyScore } from "@/src/core"; + +/** The model picker's search, arrow-walk, row styling and scrolling machine. + * Models and dictation languages supply only their catalog and row content. + * The caller owns the shared MenuShell and dismissal behavior. */ +export function SearchMenuList({ + items, itemKey, searchTerms, compare, isSelected, onPick, renderItem, + searchTestid, listTestid, optionTestid, placeholder, ariaLabel, loading, empty, + disabled = false, +}: { + items: readonly T[] | undefined; + itemKey: (item: T) => string; + searchTerms: (item: T) => string[]; + compare?: (a: T, b: T) => number; + isSelected: (item: T) => boolean; + onPick: (item: T) => void; + renderItem: (item: T) => ReactNode; + searchTestid: string; + listTestid: string; + optionTestid: (item: T) => string; + placeholder: string; + ariaLabel?: string; + loading?: string; + empty: string; + disabled?: boolean; +}) { + const [query, setQuery] = useState(""); + const [menuIdx, setMenuIdx] = useState(null); + const searchRef = useRef(null); + const listRef = useRef(null); + + useEffect(() => { searchRef.current?.focus(); }, []); + useEffect(() => { + if (menuIdx === null) return; + listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" }); + }, [menuIdx]); + + const results = (items ?? []) + .map((item) => ({ item, score: fuzzyScore(query, ...searchTerms(item)) })) + .filter((result) => result.score !== null) + .sort((a, b) => b.score! - a.score! || (compare?.(a.item, b.item) ?? 0)) + .map(({ item }) => item); + + return <> + { setQuery(event.target.value); setMenuIdx(null); }} + onKeyDown={(event) => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + event.stopPropagation(); + if (disabled || results.length === 0) return; + setMenuIdx((i) => event.key === "ArrowDown" + ? Math.min((i ?? -1) + 1, results.length - 1) + : Math.max((i ?? results.length) - 1, 0)); + } else if (event.key === "Enter") { + const chosen = results[menuIdx ?? 0]; + if (!disabled && chosen !== undefined) { + event.preventDefault(); + event.stopPropagation(); + onPick(chosen); + } + } + }} + placeholder={placeholder} + className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring" + /> + {!items && loading &&
{loading}
} + {items && results.length === 0 &&
{empty}
} +
    + {results.map((item, i) =>
  • + +
  • )} +
+ ; +} diff --git a/docs/development.md b/docs/development.md index 0acbdd5..0eee7fe 100644 --- a/docs/development.md +++ b/docs/development.md @@ -176,7 +176,7 @@ pricing-generation step: Pi AI's catalog and public calculator are the authority **Practice (problem sets).** A node selection resolves to the STORED reasoning chains it fully contains (a chain rides iff every member is selected; unchained nodes ride as singletons); the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — a full-bleed sortable table of every reasoning chain, grouped by birth cluster under the shared top bar, selecting chips, chains, or whole clusters, whose floating selection bar shows the exact `N nodes → M chains` the agents will receive — handing a free-form node selection to the same dialog — and carries the fenced Delete action, making it the graph's management view (whole-cluster cleanup through the same full-death confirm dialog). A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot. -**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. Settings → General → Dictation → Language offers Nova-3's documented monolingual languages/locales or `language=multi` for automatic multilingual transcription. The app-wide `dictation.language` preference persists through the existing settings record and `PUT /api/settings/dictation`; absent preferences resolve to English (`en`). The settings control locks from connecting through stopping, and reconnects keep the run's original language. A shared preference controller publishes confirmed PUT responses to every reader immediately; subsequent read failures cannot revert a confirmed choice. External settings notifications invalidate the cache, and capture waits for a successful refresh. Navigating away cancels the run, invalidates pending startup/reconnect work and releases its microphone/socket without appending into another page's input. All other streaming parameters remain unchanged. The picker composes the shared Button, Input, Hint and MenuShell with the existing `cmdk` dependency for accessible list navigation, using the app's fuzzy-search scoring. +**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. Settings → Providers → Dictation → Language (below the Deepgram key) offers Nova-3's documented monolingual languages/locales or `language=multi` for automatic multilingual transcription. The app-wide `dictation.language` preference persists through the existing settings record and `PUT /api/settings/dictation`; absent preferences resolve to English (`en`). The settings control locks from connecting through stopping, and reconnects keep the run's original language. A shared preference controller publishes confirmed PUT responses to every reader immediately; the confirmed preference is cached across page navigation and reopening Settings, with no polling or per-recording read. Explicit external dictation-settings notifications invalidate the cache, and capture waits for a successful refresh. Navigating away cancels the run, invalidates pending startup/reconnect work and releases its microphone/socket without appending into another page's input. All other streaming parameters remain unchanged. The picker uses the model dropdown's shared `SearchMenuList` and `MenuShell`: the same fuzzy search, arrow navigation, Enter selection, row styling and scrolling. ## Testing diff --git a/tests/dictation-flow.test.tsx b/tests/dictation-flow.test.tsx index cd6d20f..0cca1a5 100644 --- a/tests/dictation-flow.test.tsx +++ b/tests/dictation-flow.test.tsx @@ -53,6 +53,7 @@ async function fixture(language: "pl" | "en" = "pl", delayedRead?: Promise | undefined, nextMic: undefined as Promise<{ getTracks: () => { stop: () => void }[] }> | undefined, readFails: false, + reads: 0, active: false, captures: 0, stops: 0, @@ -72,6 +73,7 @@ async function fixture(language: "pl" | "en" = "pl", delayedRead?: Promise { if (url === "/api/stt/token") return Response.json({ token: "test-token" }); if (url === "/api/settings") { + state.reads++; if (state.nextRead) { const response = state.nextRead; state.nextRead = undefined; return response; } return state.readFails ? Response.json({}, { status: 500 }) : Response.json({ settings: state.record }); } @@ -101,7 +103,16 @@ async function fixture(language: "pl" | "en" = "pl", delayedRead?: Promise { await act(async () => { root.render(); }); }; + let firstMount = true; + const mount = async () => { + await act(async () => { root.render(); }); + // Each fixture is a new server record. Explicitly invalidate a prior + // fixture's window-lifetime cache, but do not refetch on remounts. + if (firstMount && setting.language !== undefined) { + await act(async () => { notifySettingsSaved("dictation"); }); + } + firstMount = false; + }; dispose = async () => { await act(async () => { root.unmount(); }); }; return { state, @@ -170,14 +181,16 @@ test("a failed save keeps the previous language, including a shortcut pressed wh assert.equal(Socket.all[0]!.language, "pl"); }); -test("successful choices survive unmount/remount and failed reads never start capture", async () => { +test("confirmed choices stay cached across navigation; failed explicit refreshes block capture", async () => { const f = await fixture(); await f.mount(); await act(async () => { assert.equal(await f.setting.save("multi"), true); }); - await f.remount(); - assert.equal(f.setting.language, "multi"); + const reads = f.state.reads; f.state.readFails = true; await f.remount(); + assert.equal(f.setting.language, "multi"); + assert.equal(f.state.reads, reads, "navigation must not reread a confirmed preference"); + await act(async () => { notifySettingsSaved("dictation"); }); await act(async () => { f.dictation.toggle(); }); assert.match(f.dictation.error!, /Could not load/); assert.equal(f.state.captures, 0); diff --git a/tests/dictation-language.test.ts b/tests/dictation-language.test.ts index 2b75ad6..cb4b910 100644 --- a/tests/dictation-language.test.ts +++ b/tests/dictation-language.test.ts @@ -12,7 +12,7 @@ describe("Nova-3 dictation language contract", () => { it("offers monolingual languages/locales and a distinct multilingual mode", () => { assert.equal(DICTATION_LANGUAGES[0]?.code, "multi"); assert.equal(new Set(DICTATION_LANGUAGES.map((item) => item.code)).size, DICTATION_LANGUAGES.length); - for (const code of ["en", "pl", "en-GB", "pt-BR", "zh-Hant", "ar-EG", "multi"]) { + for (const code of ["en", "fr", "ru", "sv", "pl", "en-GB", "pt-BR", "zh-Hant", "ar-EG", "multi"]) { assert.ok(isDictationLanguage(code), code); } // Xhosa and Zulu are documented for multi, not monolingual Nova-3. @@ -24,7 +24,7 @@ describe("Nova-3 dictation language contract", () => { }); it("only language varies in the streaming URL; all tuned parameters survive", () => { - for (const language of ["en", "pl", "multi", "en-GB"] as const) { + for (const { code: language } of DICTATION_LANGUAGES) { const url = new URL(deepgramListenUrl(language)); assert.equal(url.origin + url.pathname, "wss://api.deepgram.com/v1/listen"); assert.deepEqual(Object.fromEntries(url.searchParams), { @@ -74,4 +74,9 @@ it("the section route persists language independently and rejects unsupported va assert.deepEqual(readSettings(), saved); assert.equal((await put("unsupported")).status, 400); assert.deepEqual(readSettings(), saved); + const general = await PUT(new Request("http://localhost/api/settings/general", { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ autoUpdate: true }), + }), { params: Promise.resolve({ section: "general" }) }); + assert.equal(general.status, 200); + assert.deepEqual(readSettings(), { ...saved, general: { autoUpdate: true } }, "auto-update cannot erase dictation language"); }); diff --git a/tests/dictation-picker.test.tsx b/tests/dictation-picker.test.tsx index f33d350..e351d22 100644 --- a/tests/dictation-picker.test.tsx +++ b/tests/dictation-picker.test.tsx @@ -3,7 +3,9 @@ import assert from "node:assert/strict"; import { afterEach, test } from "node:test"; import { parseHTML } from "linkedom"; import { setDictationActive } from "../components/dictation-state.ts"; -import { DICTATION_LANGUAGES, type UserSettings } from "../src/core/index.ts"; +import { notifySettingsSaved } from "../components/settings-signal.ts"; +import { AUTH_KEYS, DICTATION_LANGUAGES, type AuthKeyStatus, type ModelCatalogEntry, type UserSettings } from "../src/core/index.ts"; +import { ModelMenuList } from "../components/settings/model-menu.tsx"; let dispose: (() => Promise) | undefined; let restore: (() => void) | undefined; @@ -19,9 +21,8 @@ async function key(element: HTMLElement, value: string) { element.dispatchEvent(event); }); } -async function search(value: string) { +async function search(value: string, element = input()) { await act(async () => { - const element = input(); Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call(element, value); element.dispatchEvent(new window.Event("input", { bubbles: true })); }); @@ -40,6 +41,7 @@ async function fixture(failRead = false) { record: { version: 1, dictation: { language: "en" } } as UserSettings, writes: [] as string[], failWrite: false, outerEscapes: 0, failRead, + keys: [] as AuthKeyStatus[], nextWrite: undefined as Promise | undefined, }; const previous = new Map(); @@ -51,6 +53,7 @@ async function fixture(failRead = false) { requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0), cancelAnimationFrame: clearTimeout, fetch: async (url: string, init?: RequestInit) => { + if (url === "/api/keys") return Response.json({ keys: state.keys, providers: [] }); if (url === "/api/settings") return state.failRead ? Response.json({}, { status: 500 }) : Response.json({ settings: state.record }); assert.equal(url, "/api/settings/dictation"); const body = JSON.parse(init!.body as string); @@ -79,7 +82,14 @@ async function fixture(failRead = false) { const mount = async () => { await act(async () => { root.render(); }); }; dispose = async () => { await act(async () => { setDictationActive(false); root.unmount(); }); }; await mount(); - return { state, remount: async () => { await dispose!(); root = createRoot(doc.getElementById("root")!); await mount(); } }; + // Fixtures represent distinct server records; remounts within a fixture + // deliberately keep the window-lifetime confirmed preference. + await act(async () => { notifySettingsSaved("dictation"); }); + return { + state, + render: async (element: React.ReactNode) => { await act(async () => { root.render(element); }); }, + remount: async () => { await dispose!(); root = createRoot(doc.getElementById("root")!); await mount(); }, + }; } afterEach(async () => { await dispose?.(); restore?.(); dispose = undefined; restore = undefined; }); @@ -102,12 +112,11 @@ test("the real picker searches by language, chooses with Arrow/Enter, and restor assert.match(trigger().textContent!, /English/); assert.equal(trigger().getAttribute("data-variant"), "outline"); await click(trigger()); - assert.equal(input().getAttribute("data-slot"), "input"); - assert.equal(input().getAttribute("role"), "combobox"); + assert.match(input().className, /lh-input/); assert.equal(document.activeElement, input()); - assert.equal(document.querySelector("[cmdk-item]")!.textContent, "Multilingual"); + assert.equal(document.querySelector('[data-testid="dictation-language-list"] button')!.textContent, "Multilingual"); await search("Polish"); - assert.equal(document.querySelectorAll("[cmdk-item]").length, 1); + assert.equal(document.querySelectorAll('[data-testid="dictation-language-list"] button').length, 1); await key(input(), "ArrowDown"); await key(input(), "Enter"); assert.deepEqual(f.state.writes, ["pl"]); @@ -122,7 +131,7 @@ test("empty results cannot save; Escape and outside click close the menu without const f = await fixture(); await click(trigger()); await search("zzzz-no-language"); - assert.match(document.querySelector("[cmdk-empty]")!.textContent!, /No languages match/); + assert.match(menu()!.textContent!, /No languages match/); await key(input(), "Enter"); assert.deepEqual(f.state.writes, []); await key(input(), "Escape"); @@ -183,10 +192,73 @@ test("capture locks the row and dismisses an open language menu until capture en assert(menu()); }); +for (const source of ["none", "file", "env"] as const) test(`Language is below the full key row for ${source}-owned keys, including editing`, async () => { + const f = await fixture(); + f.state.keys = [{ + id: "DEEPGRAM_API_KEY", info: AUTH_KEYS.find((key) => key.id === "DEEPGRAM_API_KEY")!, + present: source !== "none", masked: source === "none" ? null : "…test (32 chars)", + source, shadowedByEnv: false, + }]; + const { KeysSection } = await import("../components/settings/keys-section.tsx"); + await f.render(); + const group = byTest("group-dictation"); + const rows = group.lastElementChild!.children; + assert.equal(rows[0]!.getAttribute("data-testid"), "key-deepgram_api_key"); + assert.equal(rows[1]!.getAttribute("data-testid"), "row-dictation-language"); + assert.equal(trigger().disabled, false, "language is independent of key ownership"); + await click(byTest("key-deepgram_api_key-row")); + if (source !== "env") assert.ok(byTest("key-deepgram_api_key-editor")); + await click(trigger()); + assert(menu()); + await key(input(), "Escape"); + assert.equal(menu(), null); + assert.equal(f.state.outerEscapes, 0); + if (source !== "env") assert.ok(byTest("key-deepgram_api_key-editor"), "first Escape closes only the menu"); +}); + +test("the shared model menu preserves ranking, provider identity, keyboard clamping, and row content", async () => { + const f = await fixture(); + const model = (id: string, provider: string): ModelCatalogEntry => ({ + id, provider, name: `Model ${id}`, contextWindow: 32000, maxTokens: 4096, + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + input: ["text"], reasoning: false, supportedLevels: [], + }); + const models = [model("beta", "p1"), model("alpha", "p1"), model("alpha", "p2")]; + const picked: ModelCatalogEntry[] = []; + await f.render( picked.push(m)} footer="Model footer" />); + const modelInput = document.querySelector('[data-testid="model-search"]')!; + const options = () => [...document.querySelectorAll('[data-testid="model-list"] button')]; + assert.equal(document.activeElement, modelInput); + assert.match(options()[0]!.textContent!, /p1 · alpha/); + assert.match(options()[1]!.textContent!, /p2 · alpha/); + assert.ok(!options()[0]!.className.includes("bg-muted/60")); + assert.ok(options()[1]!.className.includes("bg-muted/60")); + assert.match(options()[0]!.textContent!, /\$1\.00\/\$2\.00/); + assert.match(options()[0]!.textContent!, /32k/); + assert.match(document.body.textContent!, /Model footer/); + await key(modelInput, "Enter"); + assert.equal(picked[0], models[1], "Enter without walking selects the first ranked result"); + await key(modelInput, "ArrowUp"); + await key(modelInput, "Enter"); + assert.equal(picked[1], models[0], "ArrowUp enters at the last result"); + await key(modelInput, "ArrowDown"); + await key(modelInput, "ArrowDown"); + await key(modelInput, "Enter"); + assert.equal(picked[2], models[0], "ArrowDown clamps at the last result"); + await search("p2", modelInput); + assert.equal(options().length, 1); + await key(modelInput, "Enter"); + assert.equal(picked[3], models[2], "search resets the walked index and includes provider identity"); + await search("no-model-matches", modelInput); + await key(modelInput, "Enter"); + assert.equal(picked.length, 4); + assert.match(document.body.textContent!, /No models match/); +}); + test("clearing each search restores the complete catalog order with Multilingual first", async () => { await fixture(); await click(trigger()); - const order = () => [...document.querySelectorAll("[cmdk-item]")].map((item) => item.getAttribute("data-value")); + const order = () => [...document.querySelectorAll('[data-testid="dictation-language-list"] button')].map((item) => item.getAttribute("data-testid")!.replace("dictation-language-", "")); const expected = DICTATION_LANGUAGES.map((item) => item.code); assert.deepEqual(order(), expected); for (const query of ["en", "multi", "Polish", "zzzzz"]) { diff --git a/tests/dictation-preference.test.ts b/tests/dictation-preference.test.ts index 4b9b7d4..233c276 100644 --- a/tests/dictation-preference.test.ts +++ b/tests/dictation-preference.test.ts @@ -24,6 +24,23 @@ test("all readers immediately receive the confirmed write without another GET", off(); }); +test("ordinary loads and repeated recordings reuse the initial confirmed value", async () => { + let reads = 0; + const store = createDictationLanguageStore(async () => { + reads++; + if (reads > 1) throw new Error("should not reread"); + return Response.json({ settings: { version: 1, dictation: { language: "fr" } } }); + }); + assert.deepEqual(await Promise.all([store.getLanguage(), store.load()]), ["fr", "fr"]); + for (let i = 0; i < 3; i++) { + const unsubscribe = store.subscribe(() => {}); + assert.equal(await store.getLanguage(), "fr"); + assert.equal(await store.load(), "fr"); + unsubscribe(); + } + assert.equal(reads, 1); +}); + test("a failed external refresh invalidates the cache until a successful retry", async () => { let fail = false; const store = createDictationLanguageStore(async () => fail From f5a655b2f96893d6ba867a831c688c59164b7ef2 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 22:01:16 +0200 Subject: [PATCH 47/87] Preserve native Enter activation on dictation language options --- components/dictation-language.tsx | 7 ++++++- tests/dictation-picker.test.tsx | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/components/dictation-language.tsx b/components/dictation-language.tsx index 5f6528b..eeec821 100644 --- a/components/dictation-language.tsx +++ b/components/dictation-language.tsx @@ -86,7 +86,12 @@ function DictationLanguagePicker({ preference, disabled }: { >
{ closeOnEscape(event); - if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); } + if (event.key === "Enter") { + // Empty search results must not submit the composer, but a + // focused option button still needs Enter's native click. + if (event.target instanceof HTMLInputElement) event.preventDefault(); + event.stopPropagation(); + } }}> {error &&
{error}
} { + const f = await fixture(); + await click(trigger()); + const option = byTest("dictation-language-multi"); + option.focus(); + let prevented = false; + await act(async () => { + const event = new window.Event("keydown", { bubbles: true, cancelable: true }); + Object.assign(event, { key: "Enter" }); + option.dispatchEvent(event); + prevented = event.defaultPrevented; + // linkedom does not implement native keyboard activation. Follow the + // browser's default action only if the handler left it uncancelled. + if (!prevented) option.click(); + }); + assert.equal(prevented, false, "focused option Enter must retain its native click"); + assert.deepEqual(f.state.writes, ["multi"]); + assert.equal(menu(), null); + assert.match(trigger().textContent!, /Multilingual/); +}); + test("empty results cannot save; Escape and outside click close the menu without closing settings", async () => { const f = await fixture(); await click(trigger()); From 27e277099f83bccc72292f86b07e1073f0ca39fa Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 22:18:01 +0200 Subject: [PATCH 48/87] Align dictation controls with settings dropdowns and show setup guidance --- components/dictation-language.tsx | 25 ++++++++----------- components/settings/agent-section.tsx | 18 +++++--------- components/settings/appearance-section.tsx | 23 ++++++----------- components/settings/controls.tsx | 26 ++++++++++++++++++- components/settings/keys-section.tsx | 12 ++++++++- docs/development.md | 2 +- tests/dictation-picker.test.tsx | 10 +++++++- tests/render-settings.test.tsx | 29 ++++++++++++++++++++++ 8 files changed, 100 insertions(+), 45 deletions(-) diff --git a/components/dictation-language.tsx b/components/dictation-language.tsx index eeec821..b0db181 100644 --- a/components/dictation-language.tsx +++ b/components/dictation-language.tsx @@ -1,12 +1,10 @@ "use client"; import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; -import { ChevronDownIcon } from "lucide-react"; import { DICTATION_LANGUAGES, type DictationLanguage } from "@/src/core"; import { MenuShell } from "@/components/menu-shell"; -import { Button } from "@/components/ui/button"; import { Hint } from "@/components/ui/hint"; -import { SettingsRow } from "@/components/settings/controls"; +import { SettingsMenuTrigger } from "@/components/settings/controls"; import { SearchMenuList } from "@/components/settings/search-menu-list"; import { useDictationActive } from "@/components/dictation-state"; import { useDictationLanguage } from "@/components/dictation-preference"; @@ -16,9 +14,13 @@ type LanguagePreference = ReturnType; export function DictationLanguageSetting() { const preference = useDictationLanguage(); const active = useDictationActive(); - return + return
+ Language - ; +
; } function DictationLanguagePicker({ preference, disabled }: { @@ -58,11 +60,9 @@ function DictationLanguagePicker({ preference, disabled }: { return <> - + className="min-w-20 max-w-[65%]" + /> {open && !disabled && { if (!disabled) onClick(); }} - className={`flex h-7 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors ${ - disabled ? "cursor-not-allowed" : "hover:border-muted-foreground/50" - } ${widthClass}`} - > - {value || emptyLabel} - - ▾ - - + unavailable={disabled} + className={widthClass} + /> ); return hint ? {button} : button; } diff --git a/components/settings/appearance-section.tsx b/components/settings/appearance-section.tsx index 30d3af9..d75ee47 100644 --- a/components/settings/appearance-section.tsx +++ b/components/settings/appearance-section.tsx @@ -41,7 +41,7 @@ import { MenuShell } from "@/components/menu-shell"; import { Hint } from "@/components/ui/hint"; import { useAppearance } from "./appearance.tsx"; import { FontMenuList } from "./model-menu"; -import { CommitInput, SettingsRow, ThinSelect, Toggle } from "./controls.tsx"; +import { CommitInput, SettingsMenuTrigger, SettingsRow, ThinSelect, Toggle } from "./controls.tsx"; /** The factory reading family — the DEFAULT row's identity (unset = * this). Exported for tests. */ @@ -210,29 +210,22 @@ export function AppearanceSection({ testid="row-font" > - {/* The MenuTrigger look (agent-section's collapsed - select — it lives unexported there, so the - classes ride inline; the searchable machine is - the shared part). The tooltip rides the CLOSED - button ONLY (owner ruling 2026-09-26: never while + {/* The shared Agent/Appearance collapsed-menu trigger. + The tooltip rides the CLOSED button ONLY + (owner ruling 2026-09-26: never while the dropdown is out — it popped over the open menu when the wrap covered the whole anchor span). */} {(() => { const triggerButton = ( - + className="min-w-20" + /> ); return fontMenuOpen ? ( triggerButton diff --git a/components/settings/controls.tsx b/components/settings/controls.tsx index 4aaa385..1715be1 100644 --- a/components/settings/controls.tsx +++ b/components/settings/controls.tsx @@ -9,7 +9,7 @@ * control together) and the control itself locks — the row owns the dim. */ -import { useState, type ReactNode } from "react"; +import { useState, type ComponentPropsWithRef, type ReactNode } from "react"; import { cn } from "@/lib/utils"; /** A section block: pronounced mono heading + content. */ @@ -81,6 +81,30 @@ export function SettingsRow({ ); } +/** The existing Agent/Appearance collapsed-menu look, shared with dictation. + * `unavailable` preserves callers that gate clicks without native disabling; + * `disabled` retains native button semantics where the caller requires them. */ +export function SettingsMenuTrigger({ + value, unavailable, disabled, className, ...props +}: Omit, "value" | "children"> & { + value: string; + unavailable?: boolean; +}) { + return ; +} + /** The on/off switch — a plain button in the switch role. */ export function Toggle({ checked, diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 14680b7..e72f6fc 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -490,7 +490,17 @@ export function KeysSection({ testid={`key-${status.id.toLowerCase()}`} /> ))} - {label === "Dictation" && } + {label === "Dictation" && <> + +

+ Create a Deepgram key with the Member role or higher, then add it above. +

+ } )} diff --git a/docs/development.md b/docs/development.md index 0eee7fe..d5465bc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -176,7 +176,7 @@ pricing-generation step: Pi AI's catalog and public calculator are the authority **Practice (problem sets).** A node selection resolves to the STORED reasoning chains it fully contains (a chain rides iff every member is selected; unchained nodes ride as singletons); the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — a full-bleed sortable table of every reasoning chain, grouped by birth cluster under the shared top bar, selecting chips, chains, or whole clusters, whose floating selection bar shows the exact `N nodes → M chains` the agents will receive — handing a free-form node selection to the same dialog — and carries the fenced Delete action, making it the graph's management view (whole-cluster cleanup through the same full-death confirm dialog). A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot. -**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. Settings → Providers → Dictation → Language (below the Deepgram key) offers Nova-3's documented monolingual languages/locales or `language=multi` for automatic multilingual transcription. The app-wide `dictation.language` preference persists through the existing settings record and `PUT /api/settings/dictation`; absent preferences resolve to English (`en`). The settings control locks from connecting through stopping, and reconnects keep the run's original language. A shared preference controller publishes confirmed PUT responses to every reader immediately; the confirmed preference is cached across page navigation and reopening Settings, with no polling or per-recording read. Explicit external dictation-settings notifications invalidate the cache, and capture waits for a successful refresh. Navigating away cancels the run, invalidates pending startup/reconnect work and releases its microphone/socket without appending into another page's input. All other streaming parameters remain unchanged. The picker uses the model dropdown's shared `SearchMenuList` and `MenuShell`: the same fuzzy search, arrow navigation, Enter selection, row styling and scrolling. +**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. Settings → Providers → Dictation → Language (below the Deepgram key) offers Nova-3's documented monolingual languages/locales or `language=multi` for automatic multilingual transcription. The app-wide `dictation.language` preference persists through the existing settings record and `PUT /api/settings/dictation`; absent preferences resolve to English (`en`). The settings control locks from connecting through stopping, and reconnects keep the run's original language. A shared preference controller publishes confirmed PUT responses to every reader immediately; the confirmed preference is cached across page navigation and reopening Settings, with no polling or per-recording read. Explicit external dictation-settings notifications invalidate the cache, and capture waits for a successful refresh. Navigating away cancels the run, invalidates pending startup/reconnect work and releases its microphone/socket without appending into another page's input. All other streaming parameters remain unchanged. The picker uses the model dropdown's shared `SearchMenuList` and `MenuShell`: the same fuzzy search, arrow navigation, Enter selection, row styling and scrolling. Its compact mono `SettingsMenuTrigger` is shared with Agent and Appearance; a muted setup line below the key and language rows links the Member-or-higher key-role requirement. ## Testing diff --git a/tests/dictation-picker.test.tsx b/tests/dictation-picker.test.tsx index 4bf33b6..8e578ea 100644 --- a/tests/dictation-picker.test.tsx +++ b/tests/dictation-picker.test.tsx @@ -110,7 +110,9 @@ test("an initial load failure is visible and opening the menu again can retry", test("the real picker searches by language, chooses with Arrow/Enter, and restores focus", async () => { const f = await fixture(); assert.match(trigger().textContent!, /English/); - assert.equal(trigger().getAttribute("data-variant"), "outline"); + for (const token of ["h-7", "rounded-sm", "border-border", "bg-background", "font-ui", "text-xs"]) { + assert.ok(trigger().className.split(" ").includes(token), `shared settings trigger: ${token}`); + } await click(trigger()); assert.match(input().className, /lh-input/); assert.equal(document.activeElement, input()); @@ -226,9 +228,15 @@ for (const source of ["none", "file", "env"] as const) test(`Language is below t const rows = group.lastElementChild!.children; assert.equal(rows[0]!.getAttribute("data-testid"), "key-deepgram_api_key"); assert.equal(rows[1]!.getAttribute("data-testid"), "row-dictation-language"); + assert.equal(rows[2]!.getAttribute("data-testid"), "dictation-setup-hint"); + assert.equal(byTest("dictation-setup-hint").textContent, "Create a Deepgram key with the Member role or higher, then add it above."); + assert.match(byTest("dictation-setup-hint").className, /text-muted-foreground/); + assert.equal(byTest("dictation-setup-hint").querySelector("a")!.href, "https://developers.deepgram.com/reference/auth/tokens/grant"); + assert.match(rows[1]!.querySelector("span")!.className, /font-ui text-xs/); assert.equal(trigger().disabled, false, "language is independent of key ownership"); await click(byTest("key-deepgram_api_key-row")); if (source !== "env") assert.ok(byTest("key-deepgram_api_key-editor")); + assert.ok(byTest("dictation-setup-hint"), "guidance remains visible while the key is edited"); await click(trigger()); assert(menu()); await key(input(), "Escape"); diff --git a/tests/render-settings.test.tsx b/tests/render-settings.test.tsx index 6c21490..5cbb123 100644 --- a/tests/render-settings.test.tsx +++ b/tests/render-settings.test.tsx @@ -19,6 +19,8 @@ import { KeyboardSection } from "../components/settings/keyboard-section.tsx"; import type { GraphSettings } from "../src/core/index.ts"; import { SettingsDialogBody, SettingsDialogProvider } from "../components/settings/settings-dialog.tsx"; import { KEYBOARD_ACTIONS } from "../src/core/index.ts"; +import { DictationLanguageSetting } from "../components/dictation-language.tsx"; +import { SettingsMenuTrigger } from "../components/settings/controls.tsx"; /** The full opening tag of the element carrying a testid — attribute * order in React output is not contractual, so assertions run on the @@ -60,6 +62,33 @@ describe("SettingsDialog (the designed surface, SSR)", () => { }); }); +describe("the shared settings dropdown trigger", () => { + it("Agent, Appearance and dictation use the same compact dark mono trigger", () => { + const surfaces = [ + [renderToStaticMarkup( {}} onModelsChange={() => {}} />), "slot-model-strong"], + [renderToStaticMarkup( {}} />), "font-trigger"], + [renderToStaticMarkup(), "dictation-language"], + ] as const; + for (const [html, id] of surfaces) { + const tag = tagOf(html, id); + const classes = tag.match(/class="([^"]+)"/)![1]!.split(" "); + for (const token of ["h-7", "rounded-sm", "border", "border-border", "bg-background", "px-1.5", "font-ui", "text-xs", "hover:border-muted-foreground/50"]) { + assert.ok(classes.includes(token), `${id}: ${token}`); + } + assert.ok(html.includes("▾"), `${id}: shared dropdown indicator`); + } + }); + + it("preserves native disabling separately from callers' existing click-gated unavailable state", () => { + const disabled = tagOf(renderToStaticMarkup(), "disabled-trigger"); + const unavailable = tagOf(renderToStaticMarkup(), "unavailable-trigger"); + assert.ok(disabled.includes('disabled=""')); + assert.ok(!unavailable.includes('disabled=""')); + assert.ok(unavailable.includes("cursor-not-allowed")); + assert.ok(!unavailable.includes("hover:border-muted-foreground/50")); + }); +}); + describe("AgentSection (thin SSR)", () => { it("renders the three model SLOT rows (slots-only, fix wave: unpicked reads —, no Default option)", () => { const html = renderToStaticMarkup( {}} onModelsChange={() => {}} />); From 8337836199a40b6908919c250f297f6853face5a Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 22:32:55 +0200 Subject: [PATCH 49/87] Use sans settings labels and link Deepgram key setup --- components/dictation-language.tsx | 10 +++------- components/settings/chatgpt-connection.tsx | 4 ++-- components/settings/connection-controls.tsx | 4 ++-- components/settings/keys-section.tsx | 6 +++--- .../settings/subscription-connections.tsx | 9 ++++++--- docs/development.md | 2 +- tests/dictation-picker.test.tsx | 8 +++++--- tests/render-settings.test.tsx | 20 +++++++++++++++++++ 8 files changed, 42 insertions(+), 21 deletions(-) diff --git a/components/dictation-language.tsx b/components/dictation-language.tsx index b0db181..94a9c38 100644 --- a/components/dictation-language.tsx +++ b/components/dictation-language.tsx @@ -4,7 +4,7 @@ import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } import { DICTATION_LANGUAGES, type DictationLanguage } from "@/src/core"; import { MenuShell } from "@/components/menu-shell"; import { Hint } from "@/components/ui/hint"; -import { SettingsMenuTrigger } from "@/components/settings/controls"; +import { SettingsMenuTrigger, SettingsRow } from "@/components/settings/controls"; import { SearchMenuList } from "@/components/settings/search-menu-list"; import { useDictationActive } from "@/components/dictation-state"; import { useDictationLanguage } from "@/components/dictation-preference"; @@ -14,13 +14,9 @@ type LanguagePreference = ReturnType; export function DictationLanguageSetting() { const preference = useDictationLanguage(); const active = useDictationActive(); - return
- Language + return -
; +
; } function DictationLanguagePicker({ preference, disabled }: { diff --git a/components/settings/chatgpt-connection.tsx b/components/settings/chatgpt-connection.tsx index f9d2d69..498ccf9 100644 --- a/components/settings/chatgpt-connection.tsx +++ b/components/settings/chatgpt-connection.tsx @@ -328,12 +328,12 @@ function AccountDialog({ children, onClose, busy }: { children: React.ReactNode; } /** Shared compact settings/setup row; account selection precedes the quiet action. */ -export function ChatGPTConnection(_props: { setup?: boolean } = {}) { +export function ChatGPTConnection({ labelClassName }: { setup?: boolean; labelClassName?: string } = {}) { const ui = useContext(Context); const active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected); const accounts = ui.status.accounts.filter((a) => a.connected); return
- active ? ui.manage(active.id) : ui.connect()}> + active ? ui.manage(active.id) : ui.connect()}> {active && } diff --git a/components/settings/connection-controls.tsx b/components/settings/connection-controls.tsx index 395d604..5a92319 100644 --- a/components/settings/connection-controls.tsx +++ b/components/settings/connection-controls.tsx @@ -4,9 +4,9 @@ import { createPortal } from "react-dom"; import { useBackdropDismiss } from "@/components/backdrop-dismiss"; import { Button } from "@/components/ui/button"; -export function ConnectionRow({ label, action, disabled, onAction, children, testid, actionTestid }: { label: string; action: string; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string }) { +export function ConnectionRow({ label, action, disabled, onAction, children, testid, actionTestid, labelClassName }: { label: string; action: string; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string; labelClassName?: string }) { return
- {label} + {label} {children}
; diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index e72f6fc..198e93d 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -493,12 +493,12 @@ export function KeysSection({ {label === "Dictation" && <>

- Create a Deepgram key with the Member role or higher, then add it above. + >Deepgram API key with the Member role or higher, then add it above.

} )} diff --git a/components/settings/subscription-connections.tsx b/components/settings/subscription-connections.tsx index 6c095d1..80f3870 100644 --- a/components/settings/subscription-connections.tsx +++ b/components/settings/subscription-connections.tsx @@ -119,16 +119,19 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea ; } -export function NativeSubscriptionConnection({ providerId }: { providerId: NativeSubscriptionProviderId }) { +export function NativeSubscriptionConnection({ providerId, labelClassName }: { providerId: NativeSubscriptionProviderId; labelClassName?: string }) { const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === providerId)!; const status = ui.statuses.find((s) => s.providerId === providerId) ?? emptySubscriptionStatus(providerId); return
- s.pending)} onAction={() => ui.open(providerId)} /> + s.pending)} onAction={() => ui.open(providerId)} /> {status.error &&

{status.error}

}
; } export function SubscriptionConnections({ setup = false }: { setup?: boolean }) { - return
{NATIVE_SUBSCRIPTIONS.map((p) => )}
; + // Providers uses ordinary settings-label typography. The setup surface + // keeps its existing compact labels; dialogs and actions are untouched. + const labelClassName = setup ? undefined : "font-prose text-(length:--lh-prose-size) text-foreground"; + return
{NATIVE_SUBSCRIPTIONS.map((p) => )}
; } export function NativeSubscriptionBackgroundAccess({ provider }: { provider: string | undefined }) { const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.logicalId === provider); diff --git a/docs/development.md b/docs/development.md index d5465bc..f775f61 100644 --- a/docs/development.md +++ b/docs/development.md @@ -176,7 +176,7 @@ pricing-generation step: Pi AI's catalog and public calculator are the authority **Practice (problem sets).** A node selection resolves to the STORED reasoning chains it fully contains (a chain rides iff every member is selected; unchained nodes ride as singletons); the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — a full-bleed sortable table of every reasoning chain, grouped by birth cluster under the shared top bar, selecting chips, chains, or whole clusters, whose floating selection bar shows the exact `N nodes → M chains` the agents will receive — handing a free-form node selection to the same dialog — and carries the fenced Delete action, making it the graph's management view (whole-cluster cleanup through the same full-death confirm dialog). A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot. -**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. Settings → Providers → Dictation → Language (below the Deepgram key) offers Nova-3's documented monolingual languages/locales or `language=multi` for automatic multilingual transcription. The app-wide `dictation.language` preference persists through the existing settings record and `PUT /api/settings/dictation`; absent preferences resolve to English (`en`). The settings control locks from connecting through stopping, and reconnects keep the run's original language. A shared preference controller publishes confirmed PUT responses to every reader immediately; the confirmed preference is cached across page navigation and reopening Settings, with no polling or per-recording read. Explicit external dictation-settings notifications invalidate the cache, and capture waits for a successful refresh. Navigating away cancels the run, invalidates pending startup/reconnect work and releases its microphone/socket without appending into another page's input. All other streaming parameters remain unchanged. The picker uses the model dropdown's shared `SearchMenuList` and `MenuShell`: the same fuzzy search, arrow navigation, Enter selection, row styling and scrolling. Its compact mono `SettingsMenuTrigger` is shared with Agent and Appearance; a muted setup line below the key and language rows links the Member-or-higher key-role requirement. +**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. Settings → Providers → Dictation → Language (below the Deepgram key) offers Nova-3's documented monolingual languages/locales or `language=multi` for automatic multilingual transcription. The app-wide `dictation.language` preference persists through the existing settings record and `PUT /api/settings/dictation`; absent preferences resolve to English (`en`). The settings control locks from connecting through stopping, and reconnects keep the run's original language. A shared preference controller publishes confirmed PUT responses to every reader immediately; the confirmed preference is cached across page navigation and reopening Settings, with no polling or per-recording read. Explicit external dictation-settings notifications invalidate the cache, and capture waits for a successful refresh. Navigating away cancels the run, invalidates pending startup/reconnect work and releases its microphone/socket without appending into another page's input. All other streaming parameters remain unchanged. The picker uses the model dropdown's shared `SearchMenuList` and `MenuShell`: the same fuzzy search, arrow navigation, Enter selection, row styling and scrolling. Its compact mono `SettingsMenuTrigger` is shared with Agent and Appearance; a muted setup line below the key and language rows links the Deepgram Console and states the Member-or-higher key-role requirement. ## Testing diff --git a/tests/dictation-picker.test.tsx b/tests/dictation-picker.test.tsx index 8e578ea..97cb1d6 100644 --- a/tests/dictation-picker.test.tsx +++ b/tests/dictation-picker.test.tsx @@ -229,10 +229,12 @@ for (const source of ["none", "file", "env"] as const) test(`Language is below t assert.equal(rows[0]!.getAttribute("data-testid"), "key-deepgram_api_key"); assert.equal(rows[1]!.getAttribute("data-testid"), "row-dictation-language"); assert.equal(rows[2]!.getAttribute("data-testid"), "dictation-setup-hint"); - assert.equal(byTest("dictation-setup-hint").textContent, "Create a Deepgram key with the Member role or higher, then add it above."); + assert.equal(byTest("dictation-setup-hint").textContent, "Create a Deepgram API key with the Member role or higher, then add it above."); assert.match(byTest("dictation-setup-hint").className, /text-muted-foreground/); - assert.equal(byTest("dictation-setup-hint").querySelector("a")!.href, "https://developers.deepgram.com/reference/auth/tokens/grant"); - assert.match(rows[1]!.querySelector("span")!.className, /font-ui text-xs/); + assert.equal(byTest("dictation-setup-hint").querySelector("a")!.href, "https://console.deepgram.com/"); + assert.equal(byTest("dictation-setup-hint").querySelector("a")!.textContent, "Deepgram API key"); + assert.equal(byTest("dictation-setup-hint").querySelectorAll("a").length, 1); + assert.match(rows[1]!.querySelector(".font-prose")!.className, /text-\(length:--lh-prose-size\)/); assert.equal(trigger().disabled, false, "language is independent of key ownership"); await click(byTest("key-deepgram_api_key-row")); if (source !== "env") assert.ok(byTest("key-deepgram_api_key-editor")); diff --git a/tests/render-settings.test.tsx b/tests/render-settings.test.tsx index 5cbb123..370bb12 100644 --- a/tests/render-settings.test.tsx +++ b/tests/render-settings.test.tsx @@ -21,6 +21,7 @@ import { SettingsDialogBody, SettingsDialogProvider } from "../components/settin import { KEYBOARD_ACTIONS } from "../src/core/index.ts"; import { DictationLanguageSetting } from "../components/dictation-language.tsx"; import { SettingsMenuTrigger } from "../components/settings/controls.tsx"; +import { SubscriptionConnections } from "../components/settings/subscription-connections.tsx"; /** The full opening tag of the element carrying a testid — attribute * order in React output is not contractual, so assertions run on the @@ -89,6 +90,25 @@ describe("the shared settings dropdown trigger", () => { }); }); +describe("Providers settings-label typography", () => { + it("Language uses the standard sans SettingsRow label without changing its mono trigger", () => { + const html = renderToStaticMarkup(); + assert.ok(html.includes('Language')); + assert.ok(tagOf(html, "dictation-language").includes("font-ui text-xs")); + }); + + it("only Providers subscription labels use sans; setup labels retain their compact mono styling", () => { + const settings = renderToStaticMarkup(); + const setup = renderToStaticMarkup(); + for (const label of ["ChatGPT", "GitHub Copilot", "Grok"]) { + assert.ok(settings.includes(`${label}`), `${label}: settings sans label`); + assert.ok(setup.includes(`${label}`), `${label}: unchanged setup label`); + } + assert.equal((settings.match(/>Connect<\/button>/g) ?? []).length, 3); + assert.equal((setup.match(/>Connect<\/button>/g) ?? []).length, 3); + }); +}); + describe("AgentSection (thin SSR)", () => { it("renders the three model SLOT rows (slots-only, fix wave: unpicked reads —, no Default option)", () => { const html = renderToStaticMarkup( {}} onModelsChange={() => {}} />); From b577ec54537df204e451020ad13b16dbb221614f Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 23:12:39 +0200 Subject: [PATCH 50/87] Prepare 0.3.9 release --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f2c5467..638b775 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "learnharness", - "version": "0.3.8", + "version": "0.3.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "learnharness", - "version": "0.3.8", + "version": "0.3.9", "license": "AGPL-3.0-only", "dependencies": { "@earendil-works/pi-agent-core": "1.4.0", diff --git a/package.json b/package.json index 82e0fad..583eabc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "learnharness", - "version": "0.3.8", + "version": "0.3.9", "license": "AGPL-3.0-only", "author": { "name": "Amos Blomqvist", From 4f4b5643d458dcba1efae7f7dab94ffbb84a5360 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 23:44:48 +0200 Subject: [PATCH 51/87] test: await completed ChatGPT sign-in and consent saves Replace arbitrary event-loop budgets in two release-gate tests with fresh per-attempt sign-in notifications and the actual consent control reply, bounded by a clear watchdog. Retain all final-state assertions and explicitly verify successful connected/error-free completion. Controlled token/save gates demonstrate both old 100-turn waits return before completion; the corrected waits remain pending and retain the final persistence/UI assertions after release. Add small watchdog result/error/timeout tests. No production, workflow or dependency changes. Isolated checks: focused 87/87, full suite once 2517/2517, full ESLint and typecheck. The full run preceded three runtime-erased non-null array-index annotations; focused tests/typecheck/full ESLint passed again on final source. --- tests/chatgpt-auth.test.ts | 47 +++++++++++++++++++++++----- tests/chatgpt-connection-ui.test.tsx | 46 ++++++++++++++++++++++----- tests/completion.test.ts | 16 ++++++++++ tests/completion.ts | 15 +++++++++ 4 files changed, 109 insertions(+), 15 deletions(-) create mode 100644 tests/completion.test.ts create mode 100644 tests/completion.ts diff --git a/tests/chatgpt-auth.test.ts b/tests/chatgpt-auth.test.ts index 14c3baa..1d23c22 100644 --- a/tests/chatgpt-auth.test.ts +++ b/tests/chatgpt-auth.test.ts @@ -6,6 +6,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import type { ChatGPTStatus } from "../src/core/contracts/chatgpt.ts"; +import { completedWithin } from "./completion.ts"; const require = createRequire(import.meta.url); const { createChatGPT, fileStore, allowedAuthEndpoint, SCOPES } = require("../electron/chatgpt.cjs"); const { privateKey, publicKey } = await generateKeyPair("RS256"); @@ -611,10 +613,17 @@ test("disconnect persistence failure is not reported as local forgetting and lea } finally { f.service.close(); } }); -test("one app-wide background preference defaults ON over legacy OFF, persists with zero accounts, and never changes on auth/account operations", async () => { +for (const holdToken of [false, true]) test(`one app-wide background preference defaults ON over legacy OFF, persists with zero accounts, and never changes on auth/account operations${holdToken ? " (completion held beyond old wait budget)" : ""}`, async () => { const first = await loggedIn(); first.service.close(); const legacy = structuredClone(first.saved); legacy.registrations[0].background = false; - const f = fixture({ state: legacy }); + const tokenEntered = deferred(), releaseToken = deferred(); + let completion = deferred(); + const change = ({ status }: { status: ChatGPTStatus }) => { if (!status.pending) completion.resolve(status); }; + const f = fixture({ state: legacy, change, token: async (form, url) => { + tokenEntered.resolve(); + if (holdToken) await releaseToken.promise; + return Response.json({ access_token: "scratch-oauth-token", refresh_token: "scratch-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") }); + } }); try { assert.equal((await f.service.status()).allowBackgroundUse, true, "absent new setting ignores legacy per-account OFF"); await f.service.lease(true); @@ -624,21 +633,43 @@ test("one app-wide background preference defaults ON over legacy OFF, persists w await f.service.lease(false); const id = f.saved.activeId; await f.service.select(id); assert.equal((await f.service.status()).allowBackgroundUse, false); - await f.service.login(id); await f.callback(); - for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((r) => setImmediate(r)); + // A fresh signal per attempt: the fixture's original changed promise + // was already resolved by earlier account/consent notifications. + completion = deferred(); + await f.service.login(id); + const response = await f.callback(); assert.equal(response.status, 200); await response.text(); + const waiting = completedWithin(completion.promise, "returning verified sign-in"); + let settled = false; void waiting.then(() => { settled = true; }, () => { settled = true; }); + if (holdToken) { + await completedWithin(tokenEntered.promise, "held token request"); + // Regression only: exhaust the OLD wait while real completion is + // explicitly held, rather than relying on machine speed or sleeps. + for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((r) => setImmediate(r)); + assert.equal((await f.service.status()).pending, true); + assert.equal(f.saved.registrations[0].background, false, "the old assertion would run before replacement was saved"); + assert.equal(settled, false, "the corrected wait must still await completion"); + releaseToken.resolve(); + } + const status = await waiting; + assert.equal(status.pending, false); assert.equal(status.error, null, "reconnecting must actually succeed"); + assert.equal(status.activeId, id); assert.equal(status.accounts[0]!.connected, true); assert.equal(f.saved.allowBackgroundUse, false, "reconnecting cannot overwrite app preference"); assert.equal(f.saved.registrations[0].background, undefined, "new verified registrations omit obsolete per-account preference"); await f.service.disconnect(f.saved.activeId); assert.deepEqual(f.saved.registrations, []); assert.equal(f.saved.allowBackgroundUse, false); - const restarted = fixture({ state: f.saved }); + const restarted = fixture({ state: f.saved, change }); try { assert.equal((await restarted.service.status()).allowBackgroundUse, false, "zero-account restart preserves global OFF"); - await restarted.service.login(); await restarted.callback(); - for (let i = 0; i < 100 && (await restarted.service.status()).pending; i++) await new Promise((r) => setImmediate(r)); + completion = deferred(); + await restarted.service.login(); + const response = await restarted.callback(); assert.equal(response.status, 200); await response.text(); + const status = await completedWithin(completion.promise, "verified sign-in after zero-account restart"); + assert.equal(status.pending, false); assert.equal(status.error, null); + assert.notEqual(status.activeId, null); assert.equal(status.accounts[0]!.connected, true); assert.equal(restarted.saved.allowBackgroundUse, false); await restarted.service.consent(true); assert.equal(restarted.saved.allowBackgroundUse, true); } finally { restarted.service.close(); } - } finally { f.service.close(); } + } finally { releaseToken.resolve(); f.service.close(); } }); test("private leases bind active account, authority revision and raw visible model membership", async () => { diff --git a/tests/chatgpt-connection-ui.test.tsx b/tests/chatgpt-connection-ui.test.tsx index f5f8f74..2edcf2f 100644 --- a/tests/chatgpt-connection-ui.test.tsx +++ b/tests/chatgpt-connection-ui.test.tsx @@ -12,6 +12,7 @@ import { SettingsDialogBody } from "../components/settings/settings-dialog.tsx"; import { KeysSection } from "../components/settings/keys-section.tsx"; import { ModelControl } from "../components/model-picker.tsx"; import type { ChatGPTStatus, ChatGPTControlBridge } from "../src/core/index.ts"; +import { completedWithin } from "./completion.ts"; const dom = parseHTML(""); Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, localStorage: { getItem: () => null, setItem() {} } }); @@ -23,7 +24,7 @@ const connected = (allowBackgroundUse = true): ChatGPTStatus => ({ ...disconnect const storageError = "ChatGPT credentials could not be read or saved. Check local file access, then reconnect before making requests."; const warning = "Account disconnected locally. Remote revocation could not be confirmed."; const errorReply = () => ({ error: storageError } as ChatGPTStatus); -function deferred() { let resolve!: (v: T) => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } +function deferred() { let resolve!: (v: T) => void; let reject!: (error: unknown) => void; const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); return { promise, resolve, reject }; } function fixture(initial = disconnected(), overrides: Partial = {}) { let status = initial; const calls = { login: [] as (string | undefined)[], cancel: 0, consent: [] as boolean[], disconnect: [] as string[], select: [] as string[], logout: 0 }; @@ -494,26 +495,57 @@ test("canceled attempt's late reply cannot release a newer pending attempt or ap await act(async () => f.complete()); assert.deepEqual(f.calls.consent, []); assert.equal(f.status.allowBackgroundUse, true); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); }); -test("real main/control bridge: immediate pending, actual cancellation, verified callback, then explicit global draft persistence", async () => { +for (const holdSave of [false, true]) test(`real main/control bridge: immediate pending, actual cancellation, verified callback, then explicit global draft persistence${holdSave ? " (completion held beyond old wait budget)" : ""}`, async () => { const require = createRequire(import.meta.url); const { createChatGPT, SCOPES } = require("../electron/chatgpt.cjs"); const { chatGPTControl } = require("../electron/chatgpt-ipc.cjs"); const { privateKey, publicKey } = await generateKeyPair("RS256"); const jwk = { ...await exportJWK(publicKey), kid: "ui-fixture", alg: "RS256", use: "sig" }; - let saved: any = null, authorize!: URL, tokenCalls = 0; const listeners = new Set<() => void>(); - const service = createChatGPT({ store: { secure() {}, async read() { return saved; }, async write(s: any) { saved = structuredClone(s); } }, openExternal: async (url: string) => { authorize = new URL(url); }, onChange: () => { for (const cb of listeners) cb(); }, fetch: async (url: string, init: RequestInit) => { + const consentCompleted = deferred(), saveEntered = deferred(), releaseSave = deferred(); + let saved: any = null, authorize!: URL, tokenCalls = 0, completionArmed = false; const listeners = new Set<() => void>(); + const service = createChatGPT({ store: { secure() {}, async read() { return saved; }, async write(s: any) { + if (holdSave && s.allowBackgroundUse === false) { saveEntered.resolve(); await releaseSave.promise; } + saved = structuredClone(s); + } }, openExternal: async (url: string) => { authorize = new URL(url); }, onChange: ({ status }: { status: ChatGPTStatus }) => { + if (completionArmed && !status.pending && status.error) consentCompleted.reject(new Error(`Fixture sign-in failed: ${status.error}`)); + for (const cb of listeners) cb(); + }, fetch: async (url: string, init: RequestInit) => { if (url.endsWith("jwks.json")) return Response.json({ keys: [jwk] }); assert.equal(url, "https://auth.openai.com/api/accounts/oauth/token"); tokenCalls++; const form = init.body as URLSearchParams; const jwt = await new SignJWT({ nonce: authorize.searchParams.get("nonce"), email: "fixture@example.invalid" }).setProtectedHeader({ alg: "RS256", kid: "ui-fixture" }).setIssuer("https://auth.openai.com").setSubject("fixture-subject").setAudience(form.get("client_id")!).setIssuedAt().setExpirationTime("1h").sign(privateKey); return Response.json({ access_token: "fixture-access", refresh_token: "fixture-refresh", id_token: jwt, token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") }); } }); - window.lh = { expandWindow() {}, chatgpt: { status: () => chatGPTControl({ op: "status" }, service), login: (id) => chatGPTControl({ op: "login", id }, service), cancel: () => chatGPTControl({ op: "cancel" }, service), select: (id) => chatGPTControl({ op: "select", id }, service), disconnect: (id) => chatGPTControl({ op: "disconnect", id }, service), logout: (id) => chatGPTControl({ op: "logout", id }, service), consent: (enabled) => chatGPTControl({ op: "consent", enabled }, service), onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); } } }; + window.lh = { expandWindow() {}, chatgpt: { status: () => chatGPTControl({ op: "status" }, service), login: (id) => chatGPTControl({ op: "login", id }, service), cancel: () => chatGPTControl({ op: "cancel" }, service), select: (id) => chatGPTControl({ op: "select", id }, service), disconnect: (id) => chatGPTControl({ op: "disconnect", id }, service), logout: (id) => chatGPTControl({ op: "logout", id }, service), consent: (enabled) => { + const reply = chatGPTControl({ op: "consent", enabled }, service); + void reply.then(consentCompleted.resolve, consentCompleted.reject); + return reply; + }, onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); } } }; try { await mount(); await begin(); assert.equal((await service.status()).pending, true); await click(button("Cancel", byTest("chatgpt-account-dialog"))); assert.equal((await service.status()).pending, false); assert.equal(tokenCalls, 0); await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed")); const callback = new URL(authorize.searchParams.get("redirect_uri")!); callback.search = new URLSearchParams({ state: authorize.searchParams.get("state")!, code: "fixture-code", client_id: "oaiapp_ui_fixture" }).toString(); - await act(async () => { const response = await originalFetch(callback); await response.text(); for (let i = 0; i < 100 && (saved.allowBackgroundUse !== false || (await service.status()).pending); i++) await new Promise((r) => setImmediate(r)); }); + await act(async () => { + completionArmed = true; + // Callback acknowledgment is not verified login, nor is login completion + // the subsequent consent save. Wait for the actual control reply. + const waiting = completedWithin(consentCompleted.promise, "verified sign-in and global draft persistence"); + let settled = false; void waiting.then(() => { settled = true; }, () => { settled = true; }); + const response = await originalFetch(callback); assert.equal(response.status, 200); await response.text(); + if (holdSave) { + await completedWithin(saveEntered.promise, "held global draft save"); + // Regression only: the OLD loop returns with the save still held. + for (let i = 0; i < 100 && (saved.allowBackgroundUse !== false || (await service.status()).pending); i++) await new Promise((r) => setImmediate(r)); + assert.equal((await service.status()).pending, false, "verification finished before the consent save"); + assert.equal(saved.allowBackgroundUse, undefined, "the old assertion would run before the global draft was saved"); + assert.equal(settled, false, "the corrected wait must still await persistence"); + releaseSave.resolve(); + } + const status = await waiting; + assert.equal(status.error, null); assert.equal(status.pending, false); + assert.equal(status.allowBackgroundUse, false); assert.equal(status.accounts[0]!.connected, true); + }); assert.equal(tokenCalls, 1); assert.equal(saved.allowBackgroundUse, false); assert.equal(saved.registrations[0].background, undefined); + assert.equal(document.querySelector('[role="alert"]'), null); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert(button("Manage…")); - } finally { await act(async () => service.close()); } + } finally { releaseSave.resolve(); await act(async () => service.close()); } }); test("browser-only sign-in is disabled; foreground toolbar has no plan badge or background action", async () => { diff --git a/tests/completion.test.ts b/tests/completion.test.ts new file mode 100644 index 0000000..8e5e154 --- /dev/null +++ b/tests/completion.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { completedWithin } from "./completion.ts"; + +test("fixture completion wait returns its result and preserves its actual error", async () => { + assert.equal(await completedWithin(Promise.resolve("saved"), "fixture save"), "saved"); + const failure = new Error("fixture persistence failed"); + await assert.rejects(completedWithin(Promise.reject(failure), "fixture save"), (error) => error === failure); +}); + +test("fixture completion wait fails clearly if completion never arrives", async () => { + await assert.rejects( + completedWithin(new Promise(() => {}), "verified fixture sign-in", 0), + /Timed out after 0ms waiting for verified fixture sign-in/, + ); +}); diff --git a/tests/completion.ts b/tests/completion.ts new file mode 100644 index 0000000..b3404eb --- /dev/null +++ b/tests/completion.ts @@ -0,0 +1,15 @@ +/** Bound an actual fixture completion signal, not a count of event-loop + * turns. Always clear the watchdog, and preserve real fixture failures. */ +export async function completedWithin(completion: Promise, description: string, timeoutMs = 10_000): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + completion, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms waiting for ${description}`)), timeoutMs); + }), + ]); + } finally { + clearTimeout(timer); + } +} From 59f389d0a61846cd8acaf025f0f05513587d5dfa Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 00:20:49 +0200 Subject: [PATCH 52/87] fix: upgrade Next to bound gzip drain listeners --- package-lock.json | 388 +++++++++++++++----------- package.json | 4 +- scripts/smoke-compression-runtime.mjs | 195 +++++++++++++ scripts/smoke-server.mjs | 3 + tests/next-compression-drain.test.ts | 12 + 5 files changed, 436 insertions(+), 166 deletions(-) create mode 100644 scripts/smoke-compression-runtime.mjs create mode 100644 tests/next-compression-drain.test.ts diff --git a/package-lock.json b/package-lock.json index 638b775..cdd727e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,7 @@ "mermaid": "^11.17.0", "motion": "^13.1.1", "nanoid": "^6.0.1", - "next": "^16.3.2", + "next": "^16.4.3", "next-themes": "^0.4.6", "pdfjs-dist": "^6.3.289", "puppeteer-core": "^25.8.0", @@ -60,7 +60,7 @@ "zod": "^4.4.3" }, "devDependencies": { - "@next/eslint-plugin-next": "^16.3.3", + "@next/eslint-plugin-next": "^16.4.3", "@tailwindcss/postcss": "^4.3.3", "@types/node": "^24.0.0", "@types/react": "^19.2.18", @@ -1708,9 +1708,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -1726,13 +1726,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -1748,20 +1748,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1771,9 +1771,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -1787,9 +1787,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -1803,12 +1803,15 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1819,12 +1822,15 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1835,12 +1841,15 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1851,12 +1860,15 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1867,12 +1879,15 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1883,12 +1898,15 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1899,12 +1917,15 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1915,12 +1936,15 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1931,12 +1955,15 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1949,16 +1976,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1971,16 +2001,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1993,16 +2026,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2015,16 +2051,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2037,16 +2076,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2059,16 +2101,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2081,16 +2126,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2103,17 +2151,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -2123,16 +2171,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -2142,9 +2190,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -2161,9 +2209,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -2180,9 +2228,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -2628,15 +2676,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.2.tgz", - "integrity": "sha512-8k4YoG8cM7LWlkfzGNYCRBbFNlernLiMw4s0btVl+CmmWqn3VpYypA72/5Feb1UWdxe6tHqr5KHP4p4Y4m9luA==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.3.tgz", + "integrity": "sha512-a7tz1QCcWWkwC/rPJcPMJk/UNRu9CFhDd3OHYE5hddbJrs8BqRTalxI6GlKCWEGzRLHN+S/B7a4PAHCtm25aBQ==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.3.3", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.3.tgz", - "integrity": "sha512-pbEh30vvjKpDoTAmo1v3q2uM4JUi8QaEBpbmjWvGfoec2jLghy/WNtvzAT0bk+Ik9oz6etjt4YjXEk4BQnicCw==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.4.3.tgz", + "integrity": "sha512-oxuC06wt/71h6ACYCV9GbKOfnf5a7VlRe/p80YHTut15TNgDEmynDnaq8t/xhy5rlvk7dhItIPs6H3rvIM4kyw==", "dev": true, "license": "MIT", "dependencies": { @@ -2645,9 +2693,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.2.tgz", - "integrity": "sha512-ib5Llm93YCKoKWDh6ZaHq6QWTuOZ2bRkSnUwMmX8dsRIOkBNL1vVlSiUKSfixPL9SSh9pvukzqajk/klkn5vqg==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.3.tgz", + "integrity": "sha512-4yq5EXBt+VDtAZwTyfijaFp5D2C5oz3Ikw5a2kiX2K9nw72vrZX2q5UuAlkPtUMO1tQz6JTKpaKWjS7OOefTDQ==", "cpu": [ "arm64" ], @@ -2661,9 +2709,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.2.tgz", - "integrity": "sha512-qd98fX2+I5nYJDioW2o7nSjoxM5KvWdeDefM80igia4+C/qSIEhH4MhTE+hO/7qKM7W37/Mq+dOWp8UePSyLHw==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.3.tgz", + "integrity": "sha512-1Rvkn/LUhnEF8WxnRkOrqGhbwXui1bkWFxONKIbN+r7C+9KHDSZ+iUoj4CzymVMWUyZIyx9szcb9+kbKhD500A==", "cpu": [ "x64" ], @@ -2677,12 +2725,15 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.2.tgz", - "integrity": "sha512-vqsgb6FAOzcrCccsLXiKtAy5t8EzO+uOazuFaSkQxeY0tNONG3vpHYy8pyBafcI5SNFPTeyard6yTr6SzNGo2A==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.3.tgz", + "integrity": "sha512-dCKpXErGg3abUM6t5JTJV/ottVBTStJB5lRJTbgdUdQaQ+jNHiK56Hsc0YAEg/rinSh42hYG3HWiYmEMdilnxg==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2693,12 +2744,15 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.2.tgz", - "integrity": "sha512-xIe1eujfHUB2XcxHGddxJyu6TJRPjC5NpIkQYB/32ESkt5VkQyIAjmLRS38c+s6QY+qjtY/4KarVDzXRuD7lZQ==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.3.tgz", + "integrity": "sha512-WwYbhWkhYBvBtib1K4W0kL8MW2snBD2A5b3SN4vPpqalZbZs5+bQ1yzAdepVoOqrFfEW3dmREy4Zs1MrWcrFbw==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2709,12 +2763,15 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.2.tgz", - "integrity": "sha512-Fe0SA2j8X0kmc3aveuHD7UktO3AE2+mH3LguP60vGbz7u0z+MrDXbeb5iZFYAwR7EzzzXJ2Yk966w9mGTFMqfA==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.3.tgz", + "integrity": "sha512-QymWvfUwDEC5/lpJOsxX5VAbyoR2D13+2Jsjz4be7gT4LPqEgdpkeLIR/HFfCLgKzoMhpJbp6I/JnogyNZHcaA==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2725,12 +2782,15 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.2.tgz", - "integrity": "sha512-TFBipb+gyesI/2Ve4zVu7kGltBWN/R466G5/1gtt2lECfc22G1pjkTxu68Q9aFcOaXiRGTQfvDbQQFe7mYgxiQ==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.3.tgz", + "integrity": "sha512-Vmjd9D7DpA4Vv3cUlr82QWgJD/BQZmns801vtC5VWHg6jCIA4a5vMKpQnfrM2pyCrp2eV0ENdUg4PlN3bAc2Yw==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2741,9 +2801,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.2.tgz", - "integrity": "sha512-rVtmnNpBYIosDnKD/96dKxFsJnwnn1WRGG/HioSe8XCm2ksSHNrd2R6+hSjvTBxeMNhJ9pYeu/90cWB1nQLuNA==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.3.tgz", + "integrity": "sha512-lG1pPdPLk7gTyGb00n5JIMLdBJgjb+UEhSdZvFbhUMg0DyC6z4B7RnpSKF9VCd4imkLObVbXOFA0kFCpw7yFow==", "cpu": [ "arm64" ], @@ -2757,9 +2817,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.2.tgz", - "integrity": "sha512-H4Y2o2/JcHu8LtwzD5CXfHhwxwz8gfsx2HXDEw46Mtev5xHnEmB7HNtZtmriw5ReUOjRtcDqo7XSbU01FT9NlA==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.3.tgz", + "integrity": "sha512-NmQC9RkJdnPXRaMxtkCzobB3teVjqF33oSOnwDfi+XYGWWYpt+xlGK3mtktSKLbdjwaWbJu2OkkMiRFGUUncKg==", "cpu": [ "x64" ], @@ -13578,12 +13638,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.2.tgz", - "integrity": "sha512-/ZCaubUy17Lld1SiPWxuPbCk2ihqAxF2QNQaPZeEaEb7t1I58qhsJN187D7AfpapHAqUPXH0f/thtdW9dWgWFg==", + "version": "16.4.3", + "resolved": "https://registry.npmjs.org/next/-/next-16.4.3.tgz", + "integrity": "sha512-OqxKTGi9u80FydydsMNYNjnuje9Au+kvHlt2GZ//ftdpDHm1dxKqAaENoOOTX5wKzn+9vaKUNIfZeyZUuLhCnA==", "license": "MIT", "dependencies": { - "@next/env": "16.3.2", + "@next/env": "16.4.3", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -13597,15 +13657,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.2", - "@next/swc-darwin-x64": "16.3.2", - "@next/swc-linux-arm64-gnu": "16.3.2", - "@next/swc-linux-arm64-musl": "16.3.2", - "@next/swc-linux-x64-gnu": "16.3.2", - "@next/swc-linux-x64-musl": "16.3.2", - "@next/swc-win32-arm64-msvc": "16.3.2", - "@next/swc-win32-x64-msvc": "16.3.2", - "sharp": "^0.35.3" + "@next/swc-darwin-arm64": "16.4.3", + "@next/swc-darwin-x64": "16.4.3", + "@next/swc-linux-arm64-gnu": "16.4.3", + "@next/swc-linux-arm64-musl": "16.4.3", + "@next/swc-linux-x64-gnu": "16.4.3", + "@next/swc-linux-x64-musl": "16.4.3", + "@next/swc-win32-arm64-msvc": "16.4.3", + "@next/swc-win32-x64-msvc": "16.4.3", + "sharp": "^0.35.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -15616,9 +15676,9 @@ } }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -15633,31 +15693,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { diff --git a/package.json b/package.json index 583eabc..8d9850f 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "mermaid": "^11.17.0", "motion": "^13.1.1", "nanoid": "^6.0.1", - "next": "^16.3.2", + "next": "^16.4.3", "next-themes": "^0.4.6", "pdfjs-dist": "^6.3.289", "puppeteer-core": "^25.8.0", @@ -78,7 +78,7 @@ "katex": "$katex" }, "devDependencies": { - "@next/eslint-plugin-next": "^16.3.3", + "@next/eslint-plugin-next": "^16.4.3", "@tailwindcss/postcss": "^4.3.3", "@types/node": "^24.0.0", "@types/react": "^19.2.18", diff --git a/scripts/smoke-compression-runtime.mjs b/scripts/smoke-compression-runtime.mjs new file mode 100644 index 0000000..767d856 --- /dev/null +++ b/scripts/smoke-compression-runtime.mjs @@ -0,0 +1,195 @@ +/** Fake-input regression for Next's real compression/backpressure path. + * Runs in a disposable child so zlib instrumentation cannot affect the app. + * node scripts/smoke-compression-runtime.mjs [--standalone ] [--runtime ] + */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { once } from "node:events"; +import * as fs from "node:fs"; +import * as http from "node:http"; +import { createRequire } from "node:module"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Readable } from "node:stream"; +import { fileURLToPath } from "node:url"; + +export function verifyCompressionRuntime(root, runtime = process.execPath) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-compression-runtime-")); + try { + for (const dir of ["data", "tmp"]) fs.mkdirSync(path.join(home, dir)); + fs.writeFileSync(path.join(home, "auth.json"), "{}\n", { mode: 0o600 }); + // Allowlist rather than inherit credentials, Node preloads, or app settings. + const env = Object.fromEntries(["PATH", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT"].flatMap(key => + process.env[key] === undefined ? [] : [[key, process.env[key]]])); + const result = spawnSync(runtime, [fileURLToPath(import.meta.url), "--probe", path.resolve(root)], { + cwd: home, + env: { ...env, HOME: home, USERPROFILE: home, TMPDIR: path.join(home, "tmp"), TEMP: path.join(home, "tmp"), TMP: path.join(home, "tmp"), + LH_DATA_DIR: path.join(home, "data"), LH_AUTH_FILE: path.join(home, "auth.json"), NODE_ENV: "production", ELECTRON_RUN_AS_NODE: "1", NEXT_TELEMETRY_DISABLED: "1" }, + encoding: "utf8", timeout: 30_000, maxBuffer: 128 * 1024, + }); + assert.equal(result.error, undefined, `compression probe failed: ${result.error?.message}`); + assert.equal(result.status, 0, `compression probe failed:\n${result.stdout}${result.stderr}`); + const report = JSON.parse(result.stdout); + assert.equal(report.passed, true); + console.log(`[compression-runtime] passed (${report.node}): gzip Node/Web, identity, no-transform, disconnect`); + return report; + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +} + +async function probe(root) { + const require = createRequire(path.join(root, "package.json")); + const inside = (specifier) => { + const resolved = fs.realpathSync(require.resolve(specifier)); + const relative = path.relative(fs.realpathSync(root), resolved); + assert.ok(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative), `dependency escaped payload: ${resolved}`); + return require(resolved); + }; + const zlib = require("node:zlib"); + const descriptor = Object.getOwnPropertyDescriptor(zlib, "createGzip"); + const records = []; + const warnings = []; + const onWarning = warning => { + if (warning.name === "MaxListenersExceededWarning" && warning.type === "drain" && warning.emitter?.constructor.name === "Gzip") warnings.push(warning.message); + }; + process.on("warning", onWarning); + let active; + Object.defineProperty(zlib, "createGzip", { ...descriptor, value: (...args) => { + const stream = descriptor.value(...args); + const record = active; + record.gzip = stream; + record.registrations = 0; + record.drains = 0; + record.maxListeners = stream.getMaxListeners(); + stream.on("newListener", event => { if (event === "drain") record.registrations++; }); + const emit = stream.emit; + stream.emit = function(event, ...args) { + if (event === "drain") record.drains++; + return emit.call(this, event, ...args); + }; + return stream; + } }); + const compression = inside("next/dist/compiled/compression"); + const { pipeNodeReadableToNodeResponse, pipeToNodeResponse } = inside("next/dist/server/pipe-readable"); + const { releaseCompressionStream } = inside("next/dist/server/lib/release-compression-stream"); + const middleware = compression(); + const chunk = randomBytes(128 * 1024); + const pieces = 24; + const server = http.createServer((req, res) => { + const record = { mode: req.url, backpressure: 0, lateResumes: 0 }; + active = record; + records.push(record); + middleware(req, res, () => {}); + // Match router-server's abort teardown; do not compensate for drain ownership. + record.closed = new Promise(resolve => res.once("close", () => { + if (!res.writableFinished) releaseCompressionStream(res); + record.finished = res.writableFinished; + resolve(); + })); + res.setHeader("Content-Type", "text/plain"); + if (req.url === "/no-transform") res.setHeader("Cache-Control", "no-cache, no-transform"); + const write = res.write; + res.write = function(...args) { + const ok = write.apply(this, args); + if (!ok) record.backpressure++; + return ok; + }; + const source = Readable.from(Array.from({ length: pieces }, () => chunk)); + record.source = source; + const resume = source.resume; + source.resume = function() { + if (this.destroyed) record.lateResumes++; + return resume.call(this); + }; + record.pipe = req.url === "/web" ? pipeToNodeResponse(Readable.toWeb(source), res) : pipeNodeReadableToNodeResponse(source, res); + // Observe failures immediately, including client abort, without unhandled rejections. + record.pipe.catch(error => { record.error = error; res.destroy(); }); + }); + async function request(mode) { + await new Promise((resolve, reject) => { + const req = http.get({ host: "127.0.0.1", port: server.address().port, path: mode, agent: false, + headers: { "Accept-Encoding": mode === "/identity" ? "identity" : "gzip" } }, res => { + const chunks = []; + res.on("data", data => { + if (mode === "/abort") { res.destroy(); resolve(); return; } + chunks.push(data); + // A slow reader exercises both zlib and socket backpressure. + res.pause(); + setTimeout(() => res.resume(), 1); + }); + res.on("error", reject); + res.on("end", () => { + try { + const record = records.at(-1); + record.encoding = res.headers["content-encoding"] || "identity"; + const body = Buffer.concat(chunks); + const output = record.encoding === "gzip" ? zlib.gunzipSync(body) : body; + assert.equal(output.length, pieces * chunk.length); + for (let i = 0; i < pieces; i++) assert.deepEqual(output.subarray(i * chunk.length, (i + 1) * chunk.length), chunk); + record.payloadVerified = true; + resolve(); + } catch (error) { reject(error); } + }); + }); + req.setTimeout(5_000, () => req.destroy(new Error(`compression request stalled: ${mode}`))); + req.on("error", reject); + }); + const record = records.at(-1); + await record.closed; + await record.pipe; + assert.equal(record.error, undefined); + assert.ok(record.backpressure > 0, `${mode} did not exercise backpressure`); + if (mode === "/abort") { + assert.equal(record.finished, false); + if (!record.gzip.closed) await once(record.gzip, "close"); + assert.equal(record.gzip.destroyed, true); + assert.equal(record.source.destroyed, true); + } else { + assert.equal(record.finished, true); + assert.equal(record.payloadVerified, true); + } + if (mode === "/node" || mode === "/web") { + assert.equal(record.encoding, "gzip"); + assert.ok(record.backpressure > 10, "must exceed the original warning threshold"); + assert.ok(record.drains > 10, "must repeat real drain cycles"); + assert.equal(record.registrations, 1, "drain registrations must not scale with writes"); + assert.equal(record.gzip.listenerCount("drain"), 1, "upstream retains one response-lifetime listener"); + } + if (mode === "/identity" || mode === "/no-transform") { + assert.equal(record.gzip, undefined); + assert.equal(record.encoding, "identity"); + } else { + assert.equal(record.maxListeners, 10, "do not raise or silence the listener limit"); + if (mode !== "/web") { + // The gzip listener survives off() but must ignore post-teardown drain. + record.gzip.emit("drain"); + assert.equal(record.lateResumes, 0); + } + } + } + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + for (const mode of ["/node", "/web", "/identity", "/no-transform", "/abort"]) await request(mode); + await new Promise(resolve => setImmediate(resolve)); // Node delivers warnings asynchronously. + assert.deepEqual(warnings, []); + return { passed: true, node: process.version, cases: records.map(({ mode, backpressure, registrations, payloadVerified, finished }) => ({ mode, backpressure, registrations, payloadVerified, finished })) }; + } finally { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + process.off("warning", onWarning); + Object.defineProperty(zlib, "createGzip", descriptor); + } +} + +const args = process.argv.slice(2); +const value = (flag, fallback) => args.includes(flag) ? args[args.indexOf(flag) + 1] : fallback; +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (args[0] === "--probe") { + probe(path.resolve(args[1])).then(report => console.log(JSON.stringify(report))).catch(error => { console.error(error); process.exitCode = 1; }); + } else { + verifyCompressionRuntime(value("--standalone", path.resolve(import.meta.dirname, "../.next/standalone")), value("--runtime", process.execPath)); + } +} diff --git a/scripts/smoke-server.mjs b/scripts/smoke-server.mjs index 91b6cb5..fc67c83 100644 --- a/scripts/smoke-server.mjs +++ b/scripts/smoke-server.mjs @@ -27,6 +27,7 @@ import { spawn } from "node:child_process"; import { probeEnv, verifyDemoRuntime } from "./smoke-demo-runtime.mjs"; import { verifyProviderRuntime } from "./smoke-provider-runtime.mjs"; +import { verifyCompressionRuntime } from "./smoke-compression-runtime.mjs"; import { createRequire } from "node:module"; import * as fs from "node:fs"; import * as http from "node:http"; @@ -83,6 +84,8 @@ async function main() { verifyDemoRuntime(STANDALONE, RUNTIME); // /api/models only reads catalogs; initialize the real lazy SDK offline too. verifyProviderRuntime(STANDALONE, RUNTIME); + // A plain GET without Accept-Encoding never exercises gzip backpressure. + verifyCompressionRuntime(STANDALONE, RUNTIME); const port = await freePort(); const url = `http://127.0.0.1:${port}`; const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-smoke-")); diff --git a/tests/next-compression-drain.test.ts b/tests/next-compression-drain.test.ts new file mode 100644 index 0000000..6368544 --- /dev/null +++ b/tests/next-compression-drain.test.ts @@ -0,0 +1,12 @@ +/** Run the same fake-input gate used for the traced standalone dependency graph. */ +import assert from "node:assert/strict"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { verifyCompressionRuntime } from "../scripts/smoke-compression-runtime.mjs"; + +test("Next compression keeps drain listeners bounded through backpressure, payload delivery and disconnect", { timeout: 35_000 }, () => { + const root = fileURLToPath(new URL("../", import.meta.url)); + const report = verifyCompressionRuntime(path.resolve(root)); + assert.deepEqual(report.cases.map((item: { mode: string }) => item.mode), ["/node", "/web", "/identity", "/no-transform", "/abort"]); +}); From 07b6403d9423e5bb051a66358d003a4e7aa11ea9 Mon Sep 17 00:00:00 2001 From: Daniel Buckner Date: Sat, 24 Oct 2026 09:53:10 -0700 Subject: [PATCH 53/87] feat: custom OpenAI-compatible endpoint as a first-class provider (vision + context from /models) --- app/api/custom-provider/route.ts | 84 ++++++++++ app/api/settings/[section]/route.ts | 4 +- components/settings/keys-section.tsx | 184 ++++++++++++++++++++- components/setup-dialog.tsx | 107 ++++++++---- src/core/auth-keys.ts | 18 +++ src/core/settings-resolution.ts | 10 ++ src/core/settings.ts | 120 ++++++++++++++ src/server/custom-provider.ts | 232 +++++++++++++++++++++++++++ src/server/index.ts | 4 + src/server/keys.ts | 14 +- src/server/model-catalog.ts | 11 ++ src/server/settings-body.ts | 4 +- src/server/stores.ts | 7 + tests/custom-endpoint.test.ts | 203 +++++++++++++++++++++++ tests/settings-body.test.ts | 4 +- 15 files changed, 965 insertions(+), 41 deletions(-) create mode 100644 app/api/custom-provider/route.ts create mode 100644 src/server/custom-provider.ts create mode 100644 tests/custom-endpoint.test.ts diff --git a/app/api/custom-provider/route.ts b/app/api/custom-provider/route.ts new file mode 100644 index 0000000..ac12fc0 --- /dev/null +++ b/app/api/custom-provider/route.ts @@ -0,0 +1,84 @@ +/** + * The custom OpenAI-compatible endpoint's connect surface (custom-endpoint + * wave). It is ONE provider in the Keys list, connected exactly like the + * built-ins are connected by a key — the only extra input is a base URL. + * + * GET → the resolved non-secret config { baseUrl, models } + whether a key + * is present (masked status only; the key is a secret in the auth + * store and never enters settings.json). + * POST → connect. Body { baseUrl, key? }: validate + normalize the base URL, + * PROBE the endpoint's /models (fail fast — a broken URL never + * persists), then store the key (empty string clears it, absent + * leaves it), persist { baseUrl, models } to the settings record, and + * re-register the provider into piModels() with no restart. + * + * Discovery lives here (not a separate route) because connecting and discovering + * are one user action: paste URL + key, get a working provider. + */ +import { + deleteKey, + discoverModels, + effectiveSettings, + keyValue, + readSettingsRecord, + saveKey, + syncCustomProvider, + writeSettingsRecord, +} from "@/src/server"; +import { CUSTOM_PROVIDER, normalizeBaseUrl } from "@/src/core"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const cp = effectiveSettings().customProvider; + return Response.json({ + baseUrl: cp.baseUrl, + models: cp.models, + keyPresent: keyValue(CUSTOM_PROVIDER.envVar) !== undefined, + }); +} + +export async function POST(request: Request) { + const body = (await request.json().catch(() => null)) as { + baseUrl?: unknown; + key?: unknown; + models?: unknown; + } | null; + const base = normalizeBaseUrl(typeof body?.baseUrl === "string" ? body.baseUrl : ""); + if (!base) return Response.json({ error: "Base URL must be a valid http(s) URL" }, { status: 400 }); + + // Two intents on one endpoint, told apart by the presence of `models`: + // • `models` present → CAPABILITY UPDATE: persist the user's per-model + // image toggles (no re-discovery, no key change) and re-register. + // • `models` absent → CONNECT: probe /models, store the discovered set. + if (Array.isArray(body?.models)) { + const record = readSettingsRecord() ?? { version: 1 }; + const next = writeSettingsRecord({ ...record, customProvider: { baseUrl: base, models: body!.models } }); + syncCustomProvider(next.customProvider); + return Response.json({ ok: true, models: next.customProvider?.models ?? [] }); + } + + // A provided key is trimmed; an empty string means "clear it", an absent + // field means "leave whatever is stored". Only a real key rides the probe. + const keyProvided = typeof body?.key === "string"; + const key = keyProvided ? (body!.key as string).trim() : undefined; + + // Probe first: a URL that doesn't answer with a model list never persists, + // so a typo can't leave a half-configured provider behind. + const found = await discoverModels(base, key ? key : undefined); + if (!found.ok) return Response.json({ error: found.error }, { status: 502 }); + + if (keyProvided) { + if (key) { + const saved = saveKey(CUSTOM_PROVIDER.envVar, key); + if (!saved.ok) return Response.json({ error: saved.error }, { status: 400 }); + } else { + deleteKey(CUSTOM_PROVIDER.envVar); + } + } + + const record = readSettingsRecord() ?? { version: 1 }; + const next = writeSettingsRecord({ ...record, customProvider: { baseUrl: base, models: found.models } }); + syncCustomProvider(next.customProvider); + return Response.json({ ok: true, models: next.customProvider?.models ?? [] }); +} diff --git a/app/api/settings/[section]/route.ts b/app/api/settings/[section]/route.ts index 2c955cf..460b593 100644 --- a/app/api/settings/[section]/route.ts +++ b/app/api/settings/[section]/route.ts @@ -128,6 +128,6 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st // Read-modify-write ONE section of the record; everything else rides // along untouched (other sections, unknown keys — sanitize preserves). const next = { ...record, [section]: sectionOut }; - writeSettingsRecord(next); - return Response.json({ settings: next }); + const saved = writeSettingsRecord(next); + return Response.json({ settings: saved }); } diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 198e93d..9777756 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -24,7 +24,7 @@ */ import { useEffect, useRef, useState } from "react"; -import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core"; +import { AUTH_KEYS, CUSTOM_PROVIDER, type AuthKeyStatus, type CustomModel } from "@/src/core"; import { SettingsGroup } from "./controls.tsx"; import { SubscriptionConnections } from "./subscription-connections.tsx"; import { ProviderMenuList } from "./model-menu.tsx"; @@ -83,6 +83,10 @@ export function classifyKeys( const unknownRows: AuthKeyStatus[] = []; for (const s of statuses) { if (isTool(s.id)) continue; + // The custom endpoint's key has its OWN home (the Custom endpoint + // section), so it never shows here — not as a provider row, not as an + // "other stored variable". + if (s.id === CUSTOM_PROVIDER.envVar) continue; if (providerEnv.has(s.id) || (!s.info && providers === undefined)) providerRows.push(s); else if (!s.info) unknownRows.push(s); // (pre-interlude catalogs: a curated-but-unclassified status still @@ -453,6 +457,7 @@ export function KeysSection({ }} /> )} + {adding && (
{adding.label} @@ -561,3 +566,180 @@ function AddProviderEditor({ ); } + +/** The custom OpenAI-compatible endpoint's row in the provider list — one more + * provider, connected by a base URL + (optional) key exactly like the built-ins + * are connected by a key (the URL is the one extra field). Its status reads + * GET /api/custom-provider; Connect POSTs the connect (validate + discover + * models + register) and dispatches the keys signal so the catalog widens with + * no restart. Keyless local servers (Ollama / LM Studio) connect with no key. */ +function CustomProviderRow() { + const [status, setStatus] = useState<{ baseUrl: string; models: CustomModel[]; keyPresent: boolean } | null>(null); + const [expanded, setExpanded] = useState(false); + const [baseUrl, setBaseUrl] = useState(""); + const [key, setKey] = useState(""); + const [keyTouched, setKeyTouched] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const reload = () => + fetch("/api/custom-provider") + .then((r) => r.json()) + .then((b: { baseUrl?: string; models?: CustomModel[]; keyPresent?: boolean }) => + setStatus({ baseUrl: b.baseUrl ?? "", models: b.models ?? [], keyPresent: Boolean(b.keyPresent) }), + ) + .catch(() => setStatus({ baseUrl: "", models: [], keyPresent: false })); + useEffect(() => { + void reload(); + }, []); + + async function save() { + if (busy || !baseUrl.trim()) return; + setBusy(true); + setError(null); + const res = await fetch("/api/custom-provider", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ baseUrl, ...(keyTouched ? { key } : {}) }), + }).catch(() => null); + setBusy(false); + if (!res || !res.ok) { + const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined; + setError(res ? (b?.error ?? `Connect failed (${res.status})`) : "Connect failed. Check your connection."); + return; + } + setExpanded(false); + setKey(""); + setKeyTouched(false); + await reload(); + notifySettingsSaved("keys"); + } + + // Flip one model's image capability and persist it (capability update — no + // re-discovery, no key change). Uses the CONNECTED base URL (status.baseUrl), + // not the editor's possibly-edited field, so toggles always apply to the + // endpoint whose models are listed. + async function toggleImage(id: string) { + if (!status?.baseUrl || busy) return; + const models = status.models.map((m) => (m.id === id ? { ...m, image: !m.image } : m)); + setBusy(true); + setError(null); + const res = await fetch("/api/custom-provider", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ baseUrl: status.baseUrl, models }), + }).catch(() => null); + setBusy(false); + if (!res || !res.ok) { + const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined; + setError(res ? (b?.error ?? `Save failed (${res.status})`) : "Save failed. Check your connection."); + return; + } + await reload(); + notifySettingsSaved("keys"); + } + + if (expanded) { + return ( +
+
+ Custom endpoint + setBaseUrl(e.target.value)} + placeholder="Base URL, e.g. http://localhost:11434/v1" + aria-label="Custom endpoint base URL" + data-testid="custom-baseurl-input" + disabled={busy} + autoFocus + className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" + /> +
+
+ { + setKey(e.target.value); + setKeyTouched(true); + }} + placeholder={status?.keyPresent ? "Key set — paste to replace, empty to clear" : "API key (optional for local servers)"} + aria-label="Custom endpoint API key" + data-testid="custom-key-input" + disabled={busy} + className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" + /> + + +
+ {status && status.models.length > 0 && ( +
+ + Models — check the ones that accept images (needed for the Vision slot) + + {status.models.map((m) => ( + + ))} +
+ )} + {error && ( +

+ {error} +

+ )} +
+ ); + } + + const count = status?.models.length ?? 0; + const imgCount = status?.models.filter((m) => m.image).length ?? 0; + const line = status?.baseUrl + ? `${status.baseUrl} · ${count} model${count === 1 ? "" : "s"}${imgCount ? ` · ${imgCount} image` : ""}${status.keyPresent ? "" : " · keyless"}` + : "not set"; + + return ( +
+ +
+ ); +} diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx index 053f3eb..4c1d9ec 100644 --- a/components/setup-dialog.tsx +++ b/components/setup-dialog.tsx @@ -40,7 +40,7 @@ import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useBackdropDismiss } from "@/components/backdrop-dismiss"; import type React from "react"; -import { type ModelCatalogEntry, type ModelSlotName, type ModelsSettings } from "@/src/core"; +import { type ModelCatalogEntry, type ModelSlotName, type ModelsSettings, CUSTOM_PROVIDER } from "@/src/core"; import { prettyModel } from "@/components/usage-format"; import { ModelMenuList } from "@/components/settings/model-menu"; import { MenuShell } from "@/components/menu-shell"; @@ -65,6 +65,11 @@ const SLOT_META: Record = { }; const SLOT_ORDER: readonly ModelSlotName[] = ["strong", "cheap", "vision"]; +/** The custom endpoint's synthetic picker entry — one more provider in the + * Stage 1 roster, connected by a base URL + optional key (POST + * /api/custom-provider). Not part of the /api/keys plain-key roster. */ +const CUSTOM_PICKER: ProviderKeyInfo = { id: CUSTOM_PROVIDER.id, envVar: CUSTOM_PROVIDER.envVar, label: CUSTOM_PROVIDER.label }; + /** The GET /api/models response fields the flow consumes. */ interface ModelsBody { models: ModelCatalogEntry[]; @@ -134,6 +139,9 @@ export function SetupKeysBody({ onValue, onSave, menuAnchor, + isCustom, + baseUrl, + onBaseUrl, }: { /** The full provider roster (GET /api/keys providers) — undefined * while it loads; the picker renders an honest empty state then. */ @@ -151,6 +159,12 @@ export function SetupKeysBody({ onSave: () => void; /** The MenuShell anchor — wired by the wrapper (its triggerRef). */ menuAnchor: React.RefObject; + /** The chosen provider is the custom endpoint: a base URL field joins the + * key field (the key becomes optional — keyless local servers). All three + * optional so the plain-key callers (and tests) stay unchanged. */ + isCustom?: boolean; + baseUrl?: string; + onBaseUrl?: (v: string) => void; }) { return (
@@ -185,30 +199,44 @@ export function SetupKeysBody({ )}
{provider && ( -
- onValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && value.trim()) onSave(); - }} - placeholder="Paste key…" - aria-label={`${provider.label} key`} - data-testid="setup-key-input" - disabled={saving} - autoFocus - className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" - /> - +
+ {isCustom && ( + onBaseUrl?.(e.target.value)} + placeholder="Base URL, e.g. http://localhost:11434/v1" + aria-label="Custom endpoint base URL" + data-testid="setup-custom-baseurl" + disabled={saving} + className="h-7 w-full rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" + /> + )} +
+ onValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && (isCustom ? baseUrl?.trim() : value.trim())) onSave(); + }} + placeholder={isCustom ? "API key (optional for local servers)" : "Paste key…"} + aria-label={`${provider.label} key`} + data-testid="setup-key-input" + disabled={saving} + autoFocus + className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" + /> + +
)} {error && ( @@ -264,6 +292,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { const [keyProviders, setKeyProviders] = useState(undefined); const [keyProvider, setKeyProvider] = useState(null); const [keyValue, setKeyValue] = useState(""); + const [customBaseUrl, setCustomBaseUrl] = useState(""); const [keySaving, setKeySaving] = useState(false); const [keyError, setKeyError] = useState(null); const [keyMenuOpen, setKeyMenuOpen] = useState(false); @@ -374,14 +403,24 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { * close recovery or advance to the missing model slots. Shape-gate * 400s keep their user-facing line inline. */ async function saveKey() { - if (!keyProvider || !keyValue.trim() || keySaving) return; + if (!keyProvider || keySaving) return; + const isCustom = keyProvider.id === CUSTOM_PROVIDER.id; + if (!isCustom && !keyValue.trim()) return; setKeySaving(true); setKeyError(null); - const res = await fetch(`/api/keys/${encodeURIComponent(keyProvider.envVar)}`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ value: keyValue }), - }).catch(() => null); + // The custom endpoint connects through its own route (base URL + key + + // discovery in one action); every other provider is a plain key PUT. + const res = isCustom + ? await fetch("/api/custom-provider", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ baseUrl: customBaseUrl, key: keyValue }), + }).catch(() => null) + : await fetch(`/api/keys/${encodeURIComponent(keyProvider.envVar)}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ value: keyValue }), + }).catch(() => null); setKeySaving(false); if (!res || !res.ok) { const body = (await res?.json().catch(() => ({}))) as { error?: string } | undefined; @@ -390,6 +429,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { } setKeyProvider(null); setKeyValue(""); + setCustomBaseUrl(""); notifySettingsSaved("keys"); } @@ -420,13 +460,16 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { } const keysBody = setKeyMenuOpen((v) => !v)} onPick={(p) => { setKeyProvider(p); setKeyMenuOpen(false); setKeyError(null); }} onValue={setKeyValue} diff --git a/src/core/auth-keys.ts b/src/core/auth-keys.ts index e8e5cc9..008118c 100644 --- a/src/core/auth-keys.ts +++ b/src/core/auth-keys.ts @@ -122,6 +122,24 @@ export const AUTH_PROVIDER_KEYS: readonly AuthProviderInfo[] = [ { id: "zai-coding-cn", envVar: "ZAI_CODING_CN_API_KEY", label: "Z.AI Coding CN" }, ] as const; +/** + * THE SINGLE CUSTOM ENDPOINT's identity (custom-endpoint wave): a + * user-configured OpenAI-compatible server registered into pi-ai at runtime + * (server/custom-provider.ts). Deliberately NOT part of AUTH_PROVIDER_KEYS — + * that table is test-pinned to pi-ai's BUILT-IN providers + * (tests/auth-provider-keys.test.ts) and every row is env-var-keyed; the + * custom endpoint is keyed by a user-supplied BASE URL instead, and its API + * key rides this one reserved env var (OPTIONAL — keyless local servers such + * as Ollama / LM Studio are allowed). The Custom endpoint Settings section is + * its one home; the key is stored in auth.json under `envVar` like any other + * provider key (masked, env-reflected, no restart). + */ +export const CUSTOM_PROVIDER = { + id: "custom", + envVar: "CUSTOM_OPENAI_API_KEY", + label: "Custom endpoint", +} as const; + /** Every distinct env var a model-provider key rides in (the write side's * writability check — see isProviderEnvVar below; also drives which * PRESENT provider vars surface as status rows). */ diff --git a/src/core/settings-resolution.ts b/src/core/settings-resolution.ts index 1a1cbf9..4c41d2b 100644 --- a/src/core/settings-resolution.ts +++ b/src/core/settings-resolution.ts @@ -37,6 +37,7 @@ import { type GeneralSettings, type DictationSettings, type CompactionSettings, + type CustomModel, type GraphSettings, type ModelSlot, type ModelSlotName, @@ -95,6 +96,11 @@ export interface EffectiveSettings { /** Compaction (owner round 14): resolved concrete — absent record * section resolves to the factory 150k. */ compaction: Required; + /** The single custom OpenAI-compatible endpoint (custom-endpoint wave): + * resolved concrete — baseUrl defaults "", models default []. Per-model + * defaults (context window, cost) apply at provider-build time in the + * server (custom-provider.ts), not here. */ + customProvider: { baseUrl: string; models: CustomModel[] }; } /** The conversation pair's chain (BYOK, owner 2026-09-22): the chat's own @@ -221,6 +227,10 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings compaction: { thresholdTokens: record?.compaction?.thresholdTokens ?? FACTORY_SETTINGS.compaction.thresholdTokens, }, + customProvider: { + baseUrl: record?.customProvider?.baseUrl ?? "", + models: record?.customProvider?.models ?? [], + }, }; } diff --git a/src/core/settings.ts b/src/core/settings.ts index f7966f1..560e8d9 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -206,6 +206,83 @@ export interface ModelsSettings { vision?: ModelSlot; } +// ─── Custom OpenAI-compatible endpoint (custom-endpoint wave) ──────────────── +// ONE user-configured endpoint speaking the OpenAI /chat/completions API +// (self-hosted vLLM / Ollama / llama.cpp, a corporate proxy). It is NOT a +// special concept: it is one more provider in the Keys list, connected by a +// base URL + (optional) key exactly as the built-ins are connected by a key. +// NON-SECRET config only — base URL + the model ids discovered from it. The +// API key is a SECRET and lives in the auth store under CUSTOM_PROVIDER.envVar +// (core/auth-keys.ts), never in this record, exactly like every other key. The +// endpoint is registered into pi-ai as a normal provider +// (server/custom-provider.ts) so its models fill the same slots/roles. + +/** Default facts for a custom model. The endpoint's /models reports ids (and + * sometimes modality); these fill the rest: self-hosted endpoints are free. */ +export const CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 8192; +export const CUSTOM_MODEL_DEFAULT_MAX_TOKENS = 4096; + +/** One discovered custom model. `image` marks it as accepting image input so + * it is selectable for the Vision slot (PDFs, image tasks). Discovery sets it + * from the endpoint's /models metadata when reported; the Keys row exposes a + * checkbox for endpoints that don't report it. `context` / `maxTokens` carry + * the endpoint's reported token limits (max_input_tokens / max_output_tokens). + * Anything the endpoint omits (cost, and any unreported window) lands on the + * CUSTOM_MODEL_DEFAULT_* values at provider-build time. */ +export interface CustomModel { + id: string; + /** Accepts image input (Vision-capable). Default false (text-only). */ + image?: boolean; + /** Context window in tokens, from the endpoint. Absent → the default. */ + context?: number; + /** Max output tokens, from the endpoint. Absent → the default. */ + maxTokens?: number; +} + +/** The single custom endpoint's stored config (delta section). `models` are + * discovered from the endpoint's /models on connect; the only user-editable + * fact per model is the `image` capability toggle. */ +export interface CustomProviderSettings { + /** Base URL of the OpenAI-compatible server, NORMALIZED (see + * normalizeBaseUrl): no trailing slash, absolute http(s). Empty/absent = + * not configured. */ + baseUrl?: string; + /** Models discovered from the endpoint (refreshed on connect), each with + * its image-capability flag. */ + models?: CustomModel[]; +} + +/** THE one place the base-URL canonical form is defined (single source — the + * sanitizer stores the normalized value, so no consumer re-derives it and + * the trailing-slash edge case is defined out of existence). Trims; parses + * as an absolute URL (scheme + host lowercased by the parser); drops any + * trailing slashes (the OpenAI SDK appends /chat/completions itself). + * Returns "" for anything that is not a valid absolute http(s) URL — callers + * treat "" as "not configured". */ +export function normalizeBaseUrl(raw: string): string { + const value = raw.trim(); + if (!value) return ""; + let url: URL; + try { + url = new URL(value); + } catch { + return ""; + } + if (url.protocol !== "http:" && url.protocol !== "https:") return ""; + return url.toString().replace(/\/+$/, ""); +} + +/** THE single source of "is the custom endpoint usable?" (custom-endpoint + * wave): a non-empty base URL. The API key is OPTIONAL (keyless local + * servers), so key presence is deliberately NOT part of this — the picker + * federation, the sync, and the roster all consult this one predicate so the + * rule can never drift between them. */ +export function customProviderUsable( + cp: CustomProviderSettings | undefined, +): cp is CustomProviderSettings & { baseUrl: string } { + return Boolean(cp?.baseUrl); +} + /** First-run gate (model-slots wave, owner 2026-09-20): the app refuses * to start chats until the user has actively picked all three slots — * there ARE no defaults for 33 providers; a configured app is one whose @@ -371,6 +448,9 @@ export interface UserSettings { agent?: AgentSettings; /** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */ models?: ModelsSettings; + /** The single custom OpenAI-compatible endpoint (custom-endpoint wave). + * Non-secret config only; the API key lives in the auth store. */ + customProvider?: CustomProviderSettings; appearance?: AppearanceSettings; graph?: GraphSettings; compaction?: CompactionSettings; @@ -497,6 +577,43 @@ function sanitizeModels(raw: Record): ModelsSettings { return out; } +/** The customProvider section: a normalized base URL (invalid → dropped, i.e. + * treated as unset) and the discovered models (each `{ id, image? }`; ids + * trimmed, empties and duplicate ids dropped, `image` kept only when true; + * a non-array drops the whole list). Clamp-never-reject, like every + * sanitizer here. */ +/** A positive integer, or undefined — keeps only meaningful token counts. */ +function positiveInt(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; +} + +function sanitizeCustomProvider(raw: Record): CustomProviderSettings { + const out: CustomProviderSettings = {}; + if (typeof raw.baseUrl === "string") { + const base = normalizeBaseUrl(raw.baseUrl); + if (base) out.baseUrl = base; + } + if (Array.isArray(raw.models)) { + const byId = new Map(); + for (const m of raw.models) { + if (typeof m !== "object" || m === null) continue; + const rec = m as Record; + if (typeof rec.id !== "string") continue; + const id = rec.id.trim(); + if (!id) continue; + const model: CustomModel = { id }; + if (rec.image === true) model.image = true; + const context = positiveInt(rec.context); + if (context) model.context = context; + const maxTokens = positiveInt(rec.maxTokens); + if (maxTokens) model.maxTokens = maxTokens; + byId.set(id, model); + } + out.models = [...byId.values()]; + } + return out; +} + function sanitizeBooleanMap(raw: unknown): Record | undefined { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined; const out: Record = {}; @@ -641,6 +758,9 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null { const models = sanitizeModels(r.models as Record); if (Object.keys(models).length) out.models = models; } + if (r.customProvider !== null && typeof r.customProvider === "object" && !Array.isArray(r.customProvider)) { + out.customProvider = sanitizeCustomProvider(r.customProvider as Record); + } if (r.graph !== null && typeof r.graph === "object" && !Array.isArray(r.graph)) { out.graph = sanitizeGraph(r.graph as Record); if (r.compaction !== undefined && r.compaction !== null && typeof r.compaction === "object") { diff --git a/src/server/custom-provider.ts b/src/server/custom-provider.ts new file mode 100644 index 0000000..6c0cf3f --- /dev/null +++ b/src/server/custom-provider.ts @@ -0,0 +1,232 @@ +/** + * THE CUSTOM OPENAI-COMPATIBLE ENDPOINT (custom-endpoint wave): the deep + * module that turns the user's non-secret config (base URL + discovered model + * ids, core/settings.ts `customProvider`) into a real pi-ai provider registered + * in the process-wide `piModels()` collection — so its models fill the same + * strong/cheap/vision slots and background roles as any built-in. + * + * WHY A MODULE OWNS THIS (information hiding): the fact "a custom endpoint is + * an OpenAI-completions provider at a user URL with an optional key" would + * otherwise leak into the catalog, the keys seam, and the settings route. It + * lives here instead; those surfaces consult the pure `customProviderUsable` + * predicate (core) and call `syncCustomProvider`. The single-endpoint scope is + * special-purpose, but the builder is written general (`buildOpenAiCompatible + * Provider`) so a future multi-endpoint wave reuses it unchanged. + * + * LIFECYCLE (no restart, mirroring the keys seam): `syncCustomProvider` is + * idempotent and cheap — called at boot (stores.ts, the composition root) and + * after every connect (POST /api/custom-provider). It replaces + * or removes the provider in `piModels()` by id. The base URL is fixed at + * provider construction, so a base-URL change is a rebuild — which is exactly + * what a re-sync does. + * + * AUTH: pi-ai resolves a provider's key at request time. We read the reserved + * env var through the keys seam (`keyValue` — env-wins, file fallback, honors + * the fresh-user switch), and fall back to a placeholder when none is set so + * KEYLESS local servers (Ollama / LM Studio / llama.cpp) work. The placeholder + * is an internal detail of `resolve()` — no caller ever sees it. + */ + +import { createProvider, type ApiKeyAuth, type Api, type Model, type Provider } from "@earendil-works/pi-ai"; +import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy"; +import { piModels } from "../agent/models.ts"; +import { CUSTOM_PROVIDER } from "../core/auth-keys.ts"; +import { + CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW, + CUSTOM_MODEL_DEFAULT_MAX_TOKENS, + customProviderUsable, + normalizeBaseUrl, + type CustomModel, + type CustomProviderSettings, +} from "../core/settings.ts"; +import { keyValue } from "./keys.ts"; + +/** Placeholder bearer sent to keyless endpoints. Many local OpenAI-compatible + * servers ignore the Authorization header entirely; the OpenAI client still + * requires *a* key, so we hand it this. Never shown to the user. */ +const KEYLESS_PLACEHOLDER = "not-needed"; + +/** Narrow an unknown to a plain object (not null, not an array). */ +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** The custom endpoint's auth: resolve the reserved key through the keys + * seam, else the keyless placeholder. `resolve` is the only method pi-ai + * needs to treat the provider as configured. */ +const customEndpointAuth: ApiKeyAuth = { + name: `${CUSTOM_PROVIDER.label} API key`, + resolve: async () => { + const key = keyValue(CUSTOM_PROVIDER.envVar)?.trim(); + // Keyless local servers ignore the header; the OpenAI client still + // needs *a* key, so hand it the placeholder. + return { auth: { apiKey: key || KEYLESS_PLACEHOLDER }, source: key ? CUSTOM_PROVIDER.envVar : "keyless" }; + }, +}; + +/** Map one discovered custom model to a pi-ai chat model entry. The endpoint's + * /models reports ids plus whatever metadata it exposes; `image` sets `input` + * so a Vision-capable model is selectable for the Vision slot (PDFs, images), + * and `context` / `maxTokens` carry the endpoint's reported token limits. + * Anything the endpoint omits falls back to the CUSTOM_MODEL_DEFAULT_* values + * (self-hosted = free, modest window). `baseUrl` is stamped per-model (pi reads + * it from the model, not the provider). */ +function toPiModel(model: CustomModel, baseUrl: string): Model { + return { + id: model.id, + name: model.id, + api: "openai-completions", + provider: CUSTOM_PROVIDER.id, + baseUrl, + reasoning: false, + input: model.image ? ["text", "image"] : ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: model.context ?? CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW, + maxTokens: model.maxTokens ?? CUSTOM_MODEL_DEFAULT_MAX_TOKENS, + type: "chat", + }; +} + +/** Build a general OpenAI-compatible provider from parts (single-use today, + * general by design). `baseUrl` must already be normalized. */ +export function buildOpenAiCompatibleProvider(input: { + baseUrl: string; + models: readonly CustomModel[]; +}): Provider { + return createProvider({ + id: CUSTOM_PROVIDER.id, + name: CUSTOM_PROVIDER.label, + baseUrl: input.baseUrl, + auth: { apiKey: customEndpointAuth }, + models: input.models.map((m) => toPiModel(m, input.baseUrl)), + api: openAICompletionsApi(), + }); +} + +/** + * Re-sync the custom provider into `piModels()` from the resolved section + * (idempotent). Usable → (re)build and `setProvider` (replaces by id, so a + * base-URL/model change lands with no restart). Not usable → `deleteProvider` + * so it stops federating and any slot pinning one of its models falls back to + * the honest models_not_configured refusal. Never throws: a bad config simply + * leaves the provider absent. + */ +export function syncCustomProvider(cp: CustomProviderSettings | undefined): void { + const models = piModels(); + if (!customProviderUsable(cp)) { + models.deleteProvider(CUSTOM_PROVIDER.id); + return; + } + // `customProviderUsable` narrowed cp to a defined section with a baseUrl. + models.setProvider( + buildOpenAiCompatibleProvider({ baseUrl: normalizeBaseUrl(cp.baseUrl), models: cp.models ?? [] }), + ); +} + +/** Outcome of a model-discovery probe. */ +export type DiscoverResult = { ok: true; models: CustomModel[] } | { ok: false; error: string }; + +/** + * Probe an endpoint's `GET {baseUrl}/models` (the OpenAI list-models route) + * and return the model ids. The scheme is validated via normalizeBaseUrl + * (http/https only — the SSRF guard: this is a local app, but a user paste of + * `file://` or a stray string must never reach fetch). The key rides as a + * bearer when one is configured; keyless endpoints answer without it. A + * non-2xx or unparseable body is an honest error, never a silent empty list. + */ +export async function discoverModels(baseUrl: string, apiKey?: string): Promise { + const base = normalizeBaseUrl(baseUrl); + if (!base) return { ok: false, error: "Base URL must be a valid http(s) URL" }; + const key = apiKey?.trim() || keyValue(CUSTOM_PROVIDER.envVar)?.trim(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 15_000); + try { + const res = await fetch(`${base}/models`, { + headers: key ? { authorization: `Bearer ${key}` } : {}, + signal: controller.signal, + }); + if (!res.ok) return { ok: false, error: `Endpoint returned ${res.status} ${res.statusText}` }; + const body = (await res.json().catch(() => null)) as unknown; + const list = extractModels(body); + if (list === null) return { ok: false, error: "Endpoint response was not a model list" }; + return { ok: true, models: list }; + } catch (err) { + const msg = err instanceof Error ? (err.name === "AbortError" ? "Endpoint timed out" : err.message) : "Request failed"; + return { ok: false, error: msg }; + } finally { + clearTimeout(timer); + } +} + +/** The model rows in a list response, or null when the shape is unrecognized. + * Accepts the OpenAI `{data:[…]}`, the Ollama-style `{models:[…]}`, or a + * bare array. */ +function modelRows(body: unknown): unknown[] | null { + if (Array.isArray(body)) return body; + if (isRecord(body)) { + if (Array.isArray(body.data)) return body.data; + if (Array.isArray(body.models)) return body.models; + } + return null; +} + +/** Does a /models row advertise image input? Endpoints are inconsistent, so + * scan the common modality/capability fields (OpenRouter's + * `architecture.input_modalities`, plus `input_modalities` / `modalities` / + * `supported_modalities` / `capabilities`) for a value mentioning image or + * vision. Absent/unrecognized -> false (text-only); the Keys row exposes a + * manual toggle for endpoints that report nothing. */ +function detectImage(row: Record): boolean { + const architecture = isRecord(row.architecture) ? row.architecture : undefined; + const candidates = [ + architecture?.input_modalities, + row.input_modalities, + row.modalities, + row.supported_modalities, + row.capabilities, + ]; + for (const c of candidates) { + if (Array.isArray(c) && c.some((v) => typeof v === "string" && /image|vision/i.test(v))) return true; + } + return false; +} + +/** First positive integer among `keys` on a /models row, else undefined. + * Endpoints name the token limits inconsistently, so the caller passes an + * ordered list of candidate field names. */ +function firstPositive(row: Record, keys: readonly string[]): number | undefined { + for (const key of keys) { + const value = row[key]; + if (typeof value === "number" && Number.isInteger(value) && value > 0) return value; + } + return undefined; +} + +// Field names seen in the wild for each limit (first present wins). +const CONTEXT_KEYS = ["max_input_tokens", "context_length", "context_window", "max_context", "n_ctx", "max_seq_len", "max_position_embeddings"] as const; +const MAX_TOKEN_KEYS = ["max_output_tokens"] as const; + +/** Pull models out of a list response (entries are `{id, ...}` objects or bare + * strings), each carrying its detected image capability and token limits. + * Returns null when the shape is unrecognized - the caller reports an honest + * error rather than a misleading empty "no models". */ +function extractModels(body: unknown): CustomModel[] | null { + const rows = modelRows(body); + if (rows === null) return null; + const byId = new Map(); + for (const row of rows) { + if (typeof row === "string") { + if (row.trim()) byId.set(row, { id: row }); + continue; + } + if (isRecord(row) && typeof row.id === "string" && row.id.trim()) { + const model: CustomModel = { id: row.id }; + if (detectImage(row)) model.image = true; + const context = firstPositive(row, CONTEXT_KEYS); + if (context) model.context = context; + const maxTokens = firstPositive(row, MAX_TOKEN_KEYS); + if (maxTokens) model.maxTokens = maxTokens; + byId.set(row.id, model); + } + } + return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)); +} diff --git a/src/server/index.ts b/src/server/index.ts index a04dfb5..591054c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -45,3 +45,7 @@ export * from "./observability.ts"; export * from "./model-catalog.ts"; export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts"; export { sessionCost } from "./pricing.ts"; +// The custom OpenAI-compatible endpoint (custom-endpoint wave): the deep +// module that registers the user's endpoint into piModels() and probes it for +// models. Importing it has no side effects; the boot sync lives in stores.ts. +export * from "./custom-provider.ts"; diff --git a/src/server/keys.ts b/src/server/keys.ts index 706de52..ce05359 100644 --- a/src/server/keys.ts +++ b/src/server/keys.ts @@ -43,6 +43,7 @@ import { AUTH_KEYS, AUTH_PROVIDER_KEYS, AUTH_PROVIDER_ENV_VARS, + CUSTOM_PROVIDER, type AuthKeyInfo, type AuthKeyStatus, genericProviderKeyGuard, @@ -72,10 +73,12 @@ function boot(): AuthBoot { return g[BOOT_KEY]; } -/** Is `id` a WRITABLE key name — curated catalog or model-provider env - * var (provider-keys interlude: PUT/DELETE accept both). */ +/** Is `id` a WRITABLE key name — curated catalog, model-provider env + * var (provider-keys interlude), or the custom endpoint's reserved key + * (custom-endpoint wave: writable through the same seam, generic shape + * guard, but NOT part of the test-pinned AUTH_PROVIDER_KEYS table). */ export function isWritableAuthKey(id: string): boolean { - return isKnownAuthKey(id) || isProviderEnvVar(id); + return isKnownAuthKey(id) || isProviderEnvVar(id) || id === CUSTOM_PROVIDER.envVar; } /** The settings page's provider dropdown: every pi-ai provider that takes @@ -213,7 +216,10 @@ export function providerKeyPresent(provider: string): boolean { * reports shadowedByEnv so the page can explain it). */ export function saveKey(id: string, value: string): { ok: true; status: AuthKeyStatus } | { ok: false; error: string } { - const shape = isProviderEnvVar(id) ? genericProviderKeyGuard(id, value) : validateKeyShape(id, value); + const shape = + isProviderEnvVar(id) || id === CUSTOM_PROVIDER.envVar + ? genericProviderKeyGuard(id, value) + : validateKeyShape(id, value); if (!shape.ok) return { ok: false, error: shape.error }; const trimmed = value.trim(); authStore.setAuthVar(id, trimmed); diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index 11fbe0f..2fa8819 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -48,6 +48,8 @@ import type { } from "../core/contracts/models.ts"; import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts"; import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.ts"; +import { CUSTOM_PROVIDER } from "../core/auth-keys.ts"; +import { customProviderUsable } from "../core/settings.ts"; import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts"; /** The picker's client-safe model entry (contract: core/contracts/models.ts). */ @@ -142,6 +144,15 @@ export function catalogProviders(): CatalogProvider[] { })).concat([ { id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() }, ...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })), + // The custom endpoint (custom-endpoint wave): a row only when usable (a + // base URL is set). Its key is OPTIONAL (keyless local servers), so + // keyPresent here reflects USABILITY, not key presence — the picker + // federates exactly the usable providers. `customProviderUsable` is the + // single source of that rule, so this row and the sync in custom-provider.ts + // can never disagree. + ...(customProviderUsable(effectiveSettings().customProvider) + ? [{ id: CUSTOM_PROVIDER.id, envVar: CUSTOM_PROVIDER.envVar, keyPresent: true }] + : []), ]).sort((a, b) => a.id.localeCompare(b.id)); } diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index 9b1a173..595067e 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -387,7 +387,9 @@ export function parseDictationSettingsBody(body: unknown): SettingsBodyResult { + it("drops trailing slashes and keeps the path", () => { + assert.equal(normalizeBaseUrl("https://host:8000/v1/"), "https://host:8000/v1"); + assert.equal(normalizeBaseUrl(" https://host/v1 "), "https://host/v1"); + assert.equal(normalizeBaseUrl("http://localhost:11434"), "http://localhost:11434"); + }); + it("rejects non-http(s) schemes and junk with empty string", () => { + assert.equal(normalizeBaseUrl("file:///etc/passwd"), ""); + assert.equal(normalizeBaseUrl("not a url"), ""); + assert.equal(normalizeBaseUrl(""), ""); + }); +}); + +// ─── customProviderUsable ──────────────────────────────────────────────────── +describe("customProviderUsable", () => { + it("is true exactly when a base URL is set (the key is optional)", () => { + assert.equal(customProviderUsable({ baseUrl: "https://h/v1" }), true); + assert.equal(customProviderUsable({ baseUrl: "" }), false); + assert.equal(customProviderUsable({}), false); + assert.equal(customProviderUsable(undefined), false); + }); +}); + +// ─── sanitizer ─────────────────────────────────────────────────────────────── +describe("sanitizeUserSettings — customProvider", () => { + it("normalizes the base URL and cleans the model list (trim, dedupe, image flag)", () => { + const out = sanitizeUserSettings({ + customProvider: { + baseUrl: "https://host:8000/v1/", + models: [ + { id: " llama-3 " }, // trims id; image absent + { id: "gpt", image: true }, + { id: "gpt", image: false }, // dup id wins (last) → image dropped + { id: "" }, // dropped: no usable id + "not-an-object", // dropped + ], + }, + }); + const cp = out?.customProvider; + assert.equal(cp?.baseUrl, "https://host:8000/v1"); + assert.deepEqual(cp?.models, [{ id: "llama-3" }, { id: "gpt" }]); + }); + it("keeps image:true and drops a non-array models list / invalid base URL", () => { + const out = sanitizeUserSettings({ customProvider: { baseUrl: "ftp://x", models: "nope" } }); + assert.equal(out?.customProvider?.baseUrl, undefined); + assert.equal(out?.customProvider?.models, undefined); + }); +}); + +// ─── resolution ────────────────────────────────────────────────────────────── +describe("resolveSettings — customProvider", () => { + it("resolves an absent section to concrete defaults", () => { + assert.deepEqual(resolveSettings(null).customProvider, { baseUrl: "", models: [] }); + }); +}); + +// ─── provider build + live sync ────────────────────────────────────────────── +describe("buildOpenAiCompatibleProvider / syncCustomProvider", () => { + after(() => piModels().deleteProvider(CUSTOM_PROVIDER.id)); + + it("maps ids to defaults and marks image-capable models image-input", () => { + const provider = buildOpenAiCompatibleProvider({ + baseUrl: "http://h/v1", + models: [{ id: "m1" }, { id: "m2", image: true }], + }); + assert.equal(provider.id, CUSTOM_PROVIDER.id); + const [m1, m2] = provider.getModels(); + assert.equal(m1!.name, "m1"); + assert.equal(m1!.api, "openai-completions"); + assert.equal(m1!.provider, CUSTOM_PROVIDER.id); + assert.equal(m1!.baseUrl, "http://h/v1"); + assert.equal(m1!.contextWindow, CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW); + assert.equal(m1!.maxTokens, CUSTOM_MODEL_DEFAULT_MAX_TOKENS); + assert.deepEqual(m1!.input, ["text"]); // text-only by default + assert.deepEqual(m2!.input, ["text", "image"]); // image-capable → Vision-eligible + assert.equal(m1!.contextWindow, CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW); // no endpoint value → default + assert.equal(m1!.maxTokens, CUSTOM_MODEL_DEFAULT_MAX_TOKENS); + }); + + it("carries endpoint token limits when present", () => { + const provider = buildOpenAiCompatibleProvider({ + baseUrl: "http://h/v1", + models: [{ id: "big", context: 196608, maxTokens: 393216 }], + }); + const [big] = provider.getModels(); + assert.equal(big!.contextWindow, 196608); // 192k from the endpoint + assert.equal(big!.maxTokens, 393216); + }); + + it("registers on a usable sync and removes when the base URL clears", () => { + syncCustomProvider({ baseUrl: "http://h/v1", models: [{ id: "live" }] }); + assert.ok(piModels().getProvider(CUSTOM_PROVIDER.id), "provider registered"); + assert.ok(piModels().getModel(CUSTOM_PROVIDER.id, "live"), "model resolvable"); + syncCustomProvider({ baseUrl: "", models: [{ id: "live" }] }); + assert.equal(piModels().getProvider(CUSTOM_PROVIDER.id), undefined, "provider removed"); + }); +}); + +// ─── discovery (stubbed fetch) ─────────────────────────────────────────────── +describe("discoverModels", () => { + const realFetch = globalThis.fetch; + after(() => { + globalThis.fetch = realFetch; + }); + + function stubFetch(handler: (url: string) => { status: number; body: unknown }) { + globalThis.fetch = (async (input: unknown) => { + const res = handler(String(input)); + return new Response(JSON.stringify(res.body), { status: res.status }); + }) as unknown as typeof fetch; + } + + it("reads OpenAI {data:[{id}]} and Ollama {models:[{id}]} shapes", async () => { + stubFetch(() => ({ status: 200, body: { data: [{ id: "b" }, { id: "a" }] } })); + const openai = await discoverModels("http://h/v1"); + assert.ok(openai.ok); + assert.deepEqual(openai.models, [{ id: "a" }, { id: "b" }]); + stubFetch(() => ({ status: 200, body: { models: [{ id: "x" }] } })); + const r = await discoverModels("http://h/v1"); + assert.ok(r.ok && r.models.length === 1 && r.models[0]!.id === "x"); + }); + it("detects image capability from common modality fields", async () => { + stubFetch(() => ({ + status: 200, + body: { + data: [ + { id: "vision-model", architecture: { input_modalities: ["text", "image"] } }, + { id: "cap-model", capabilities: ["completion", "vision"] }, + { id: "text-model", architecture: { input_modalities: ["text"] } }, + ], + }, + })); + const r = await discoverModels("http://h/v1"); + assert.ok(r.ok); + const byId = Object.fromEntries(r.models.map((m) => [m.id, Boolean(m.image)])); + assert.equal(byId["vision-model"], true); + assert.equal(byId["cap-model"], true); + assert.equal(byId["text-model"], false); + }); + it("detects token limits from common metadata fields", async () => { + stubFetch(() => ({ + status: 200, + body: { + data: [ + { id: "big", max_input_tokens: 196608, max_output_tokens: 393216 }, + { id: "ctx", context_length: 32768 }, + { id: "plain" }, + ], + }, + })); + const r = await discoverModels("http://h/v1"); + assert.ok(r.ok); + const byId = Object.fromEntries(r.models.map((m) => [m.id, m])); + assert.equal(byId["big"]!.context, 196608); + assert.equal(byId["big"]!.maxTokens, 393216); + assert.equal(byId["ctx"]!.context, 32768); + assert.equal(byId["ctx"]!.maxTokens, undefined); + assert.equal(byId["plain"]!.context, undefined); + }); + it("guards the scheme before any fetch", async () => { + let called = false; + globalThis.fetch = (async () => { + called = true; + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; + const r = await discoverModels("file:///etc/passwd"); + assert.equal(r.ok, false); + assert.equal(called, false, "no fetch for a non-http scheme"); + }); + it("reports a non-2xx and an unparseable shape as honest errors", async () => { + stubFetch(() => ({ status: 500, body: {} })); + assert.equal((await discoverModels("http://h/v1")).ok, false); + stubFetch(() => ({ status: 200, body: { unexpected: true } })); + assert.equal((await discoverModels("http://h/v1")).ok, false); + }); +}); diff --git a/tests/settings-body.test.ts b/tests/settings-body.test.ts index 16c0ac9..c194c60 100644 --- a/tests/settings-body.test.ts +++ b/tests/settings-body.test.ts @@ -236,7 +236,9 @@ describe("parseCompactionSettingsBody (owner round 14)", () => { describe("SETTINGS_SECTIONS (the route's section map)", () => { it("maps exactly the eight record sections", () => { // compaction joined (owner round 14): its own section PUT — the - // threshold is engine behavior, not an agent-section key. + // threshold is engine behavior, not an agent-section key. The custom + // endpoint is NOT a section: it is one more provider in the Keys list, + // connected through POST /api/custom-provider. assert.deepEqual( Object.keys(SETTINGS_SECTIONS), ["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation"], From 870a1ad34383189a0caff81aad0832e2b0520356 Mon Sep 17 00:00:00 2001 From: Daniel Buckner Date: Sat, 24 Oct 2026 10:18:13 -0700 Subject: [PATCH 54/87] feat: custom endpoint as a first-class provider (add via dropdown, required key, vision ordering) --- app/api/custom-provider/route.ts | 17 +- components/settings/agent-section.tsx | 8 +- components/settings/keys-section.tsx | 364 +++++++++++++---------- components/settings/model-menu.tsx | 6 + components/settings/search-menu-list.tsx | 13 +- components/setup-cta.tsx | 10 +- components/setup-dialog.tsx | 13 +- tests/setup-dialog-interaction.test.tsx | 7 +- 8 files changed, 242 insertions(+), 196 deletions(-) diff --git a/app/api/custom-provider/route.ts b/app/api/custom-provider/route.ts index ac12fc0..e14bcd8 100644 --- a/app/api/custom-provider/route.ts +++ b/app/api/custom-provider/route.ts @@ -39,25 +39,10 @@ export async function GET() { } export async function POST(request: Request) { - const body = (await request.json().catch(() => null)) as { - baseUrl?: unknown; - key?: unknown; - models?: unknown; - } | null; + const body = (await request.json().catch(() => null)) as { baseUrl?: unknown; key?: unknown } | null; const base = normalizeBaseUrl(typeof body?.baseUrl === "string" ? body.baseUrl : ""); if (!base) return Response.json({ error: "Base URL must be a valid http(s) URL" }, { status: 400 }); - // Two intents on one endpoint, told apart by the presence of `models`: - // • `models` present → CAPABILITY UPDATE: persist the user's per-model - // image toggles (no re-discovery, no key change) and re-register. - // • `models` absent → CONNECT: probe /models, store the discovered set. - if (Array.isArray(body?.models)) { - const record = readSettingsRecord() ?? { version: 1 }; - const next = writeSettingsRecord({ ...record, customProvider: { baseUrl: base, models: body!.models } }); - syncCustomProvider(next.customProvider); - return Response.json({ ok: true, models: next.customProvider?.models ?? [] }); - } - // A provided key is trimmed; an empty string means "clear it", an absent // field means "leave whatever is stored". Only a real key rides the probe. const keyProvided = typeof body?.key === "string"; diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index 33f5512..aa1271f 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -600,9 +600,10 @@ export function AgentSection({ const chosen = coord ? catalog?.find((m) => m.id === coord.id && m.provider === coord.provider) : undefined; const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS; const thisMenuOpen = openMenu?.target === slot ? openMenu.kind : null; - // Vision slots pick from image-capable models only. - const slotCatalog = - slot === "vision" ? catalog?.filter((m) => m.input.includes("image")) : catalog; + // Vision slot: image-capable models float first, but all stay + // selectable (a custom endpoint that doesn't report modality must + // remain pickable) — so this orders, never filters. + const slotCatalog = catalog; return ( { // Auto-populate (chunk 2, owner: "populate everywhere"): the // fresh pick writes a level — the stored one rides as-is diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 9777756..cd80df5 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -45,12 +45,28 @@ interface KeysResponse { providers?: ProviderKeyInfo[]; } +/** The custom endpoint's non-secret status (GET /api/custom-provider). */ +interface CustomStatus { + baseUrl: string; + models: CustomModel[]; + keyPresent: boolean; +} + /** The curated tools group, pinned by id (the catalog owns the copy; * these ids are the contract). Derived from the catalog (search wave, * 2026-09-20: the Google CSE pair retired, Tavily + Brave joined) — the * section can never drift from core/auth-keys.ts again. */ const TOOL_IDS = AUTH_KEYS.map((k) => k.id) as readonly string[]; +/** The custom endpoint as it appears in the Add-provider dropdown — a synthetic + * entry (it is not one of pi-ai's 33), offered only while unconfigured; once + * connected it graduates to its own row like any stored provider. */ +const CUSTOM_PROVIDER_INFO: ProviderKeyInfo = { + id: CUSTOM_PROVIDER.id, + envVar: CUSTOM_PROVIDER.envVar, + label: CUSTOM_PROVIDER.label, +}; + /** The classification driving the table groups. Pure — exported for * tests. OpenRouter is un-pinned (wave-5 owner ruling): no synthesis, * no special row — it rides the same provider classification. */ @@ -303,7 +319,7 @@ function AddProviderRow({ onClick={() => setOpen((v) => !v)} className="flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left font-ui text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground" > - + Add provider key + + Add a provider {addable.length > 0 && {addable.length}} {open && ( @@ -346,13 +362,16 @@ export function KeysSection({ /** The paste editor for a freshly chosen Add-provider target. */ const [adding, setAdding] = useState(null); const [addError, setAddError] = useState(null); + /** The custom endpoint's status + whether its connect editor is open. */ + const [custom, setCustom] = useState(null); + const [addingCustom, setAddingCustom] = useState(false); // Escape folds any open editor (unfolded row or the Add-provider paste) // WITHOUT closing the dialog. Claimed at document CAPTURE phase — the // only stopPropagation that outruns both the input's React handler and // the dialog's window-level listener. useEffect(() => { - if (openRow === null && adding === null) return; + if (openRow === null && adding === null && !addingCustom) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key !== "Escape") return; // A language dropdown above this editor in the dismissal stack owns @@ -362,20 +381,35 @@ export function KeysSection({ e.stopPropagation(); setOpenRow(null); setAdding(null); + setAddingCustom(false); }; document.addEventListener("keydown", onKeyDown, true); return () => document.removeEventListener("keydown", onKeyDown, true); - }, [openRow, adding]); + }, [openRow, adding, addingCustom]); - const reload = () => - fetch("/api/keys") - .then((r) => r.json()) - .then((body: KeysResponse) => { - setStatuses(body.keys ?? []); - if (body.providers) setProviders(body.providers); - }) - .catch(() => setStatuses([])) - .finally(() => setLoading(false)); + const reload = async () => { + try { + const body = (await fetch("/api/keys").then((r) => r.json())) as KeysResponse; + setStatuses(body.keys ?? []); + if (body.providers) setProviders(body.providers); + } catch { + setStatuses([]); + } finally { + setLoading(false); + } + // Best-effort and independent: the custom endpoint's status must never + // blank the provider-key list above if its fetch fails. + try { + const cp = (await fetch("/api/custom-provider").then((r) => r.json())) as Partial; + setCustom({ + baseUrl: cp.baseUrl ?? "", + models: cp.models ?? [], + keyPresent: Boolean(cp.keyPresent), + }); + } catch { + /* custom status unavailable — leave it unset */ + } + }; useEffect(() => { void reload(); @@ -448,16 +482,52 @@ export function KeysSection({ testid={`key-${status.id.toLowerCase()}`} /> ))} + {custom?.baseUrl && ( + setAddingCustom((v) => !v)} + /> + )} + {custom?.baseUrl && addingCustom && ( + { + setAddingCustom(false); + await reload(); + notifySettingsSaved("keys"); + }} + onCancel={() => setAddingCustom(false)} + /> + )} {providers !== undefined && ( { - setAdding(p); setAddError(null); + if (p.id === CUSTOM_PROVIDER.id) { + setAdding(null); + setAddingCustom(true); + } else { + setAddingCustom(false); + setAdding(p); + } }} /> )} - + {!custom?.baseUrl && addingCustom && ( + { + setAddingCustom(false); + await reload(); + notifySettingsSaved("keys"); + }} + onCancel={() => setAddingCustom(false)} + /> + )} {adding && (
{adding.label} @@ -567,31 +637,65 @@ function AddProviderEditor({ ); } -/** The custom OpenAI-compatible endpoint's row in the provider list — one more - * provider, connected by a base URL + (optional) key exactly like the built-ins - * are connected by a key (the URL is the one extra field). Its status reads - * GET /api/custom-provider; Connect POSTs the connect (validate + discover - * models + register) and dispatches the keys signal so the catalog widens with - * no restart. Keyless local servers (Ollama / LM Studio) connect with no key. */ -function CustomProviderRow() { - const [status, setStatus] = useState<{ baseUrl: string; models: CustomModel[]; keyPresent: boolean } | null>(null); - const [expanded, setExpanded] = useState(false); - const [baseUrl, setBaseUrl] = useState(""); +/** The custom endpoint's status row in the provider list — one more provider, + * shown only once it's configured (exactly like a built-in appears only once + * its key is stored). Clicking re-opens the connect editor. */ +function CustomEndpointRow({ + status, + expanded, + onToggle, +}: { + status: CustomStatus; + expanded: boolean; + onToggle: () => void; +}) { + const count = status.models.length; + const line = `${status.baseUrl} · ${count} model${count === 1 ? "" : "s"}${status.keyPresent ? "" : " · keyless"}`; + return ( +
+ +
+ ); +} + +/** The custom endpoint's connect editor — base URL + optional key, exactly the + * built-ins' key-paste flow plus the one extra URL field. Connect POSTs to + * /api/custom-provider (validate + discover + register); on success the parent + * reloads so the row appears and the catalog widens with no restart. Image + * capability is detected automatically from the endpoint — there is nothing to + * tick here. Keyless local servers (Ollama / LM Studio) connect with no key. */ +function CustomEndpointEditor({ + baseUrl0, + keyPresent, + onSaved, + onCancel, +}: { + baseUrl0: string; + keyPresent: boolean; + onSaved: () => void | Promise; + onCancel: () => void; +}) { + const [baseUrl, setBaseUrl] = useState(baseUrl0); const [key, setKey] = useState(""); const [keyTouched, setKeyTouched] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - - const reload = () => - fetch("/api/custom-provider") - .then((r) => r.json()) - .then((b: { baseUrl?: string; models?: CustomModel[]; keyPresent?: boolean }) => - setStatus({ baseUrl: b.baseUrl ?? "", models: b.models ?? [], keyPresent: Boolean(b.keyPresent) }), - ) - .catch(() => setStatus({ baseUrl: "", models: [], keyPresent: false })); - useEffect(() => { - void reload(); - }, []); + // A key is required to enable chatting (the send gate needs a provider key); + // local servers accept any value. When one is already stored, leaving the + // field blank keeps it. + const needsKey = !keyPresent; + const canConnect = Boolean(baseUrl.trim()) && (!needsKey || Boolean(key.trim())); async function save() { if (busy || !baseUrl.trim()) return; @@ -608,138 +712,68 @@ function CustomProviderRow() { setError(res ? (b?.error ?? `Connect failed (${res.status})`) : "Connect failed. Check your connection."); return; } - setExpanded(false); - setKey(""); - setKeyTouched(false); - await reload(); - notifySettingsSaved("keys"); + await onSaved(); } - // Flip one model's image capability and persist it (capability update — no - // re-discovery, no key change). Uses the CONNECTED base URL (status.baseUrl), - // not the editor's possibly-edited field, so toggles always apply to the - // endpoint whose models are listed. - async function toggleImage(id: string) { - if (!status?.baseUrl || busy) return; - const models = status.models.map((m) => (m.id === id ? { ...m, image: !m.image } : m)); - setBusy(true); - setError(null); - const res = await fetch("/api/custom-provider", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ baseUrl: status.baseUrl, models }), - }).catch(() => null); - setBusy(false); - if (!res || !res.ok) { - const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined; - setError(res ? (b?.error ?? `Save failed (${res.status})`) : "Save failed. Check your connection."); - return; - } - await reload(); - notifySettingsSaved("keys"); - } - - if (expanded) { - return ( -
-
- Custom endpoint - setBaseUrl(e.target.value)} - placeholder="Base URL, e.g. http://localhost:11434/v1" - aria-label="Custom endpoint base URL" - data-testid="custom-baseurl-input" - disabled={busy} - autoFocus - className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" - /> -
-
- { - setKey(e.target.value); - setKeyTouched(true); - }} - placeholder={status?.keyPresent ? "Key set — paste to replace, empty to clear" : "API key (optional for local servers)"} - aria-label="Custom endpoint API key" - data-testid="custom-key-input" - disabled={busy} - className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" - /> - - -
- {status && status.models.length > 0 && ( -
- - Models — check the ones that accept images (needed for the Vision slot) - - {status.models.map((m) => ( - - ))} -
- )} - {error && ( -

- {error} -

- )} -
- ); - } - - const count = status?.models.length ?? 0; - const imgCount = status?.models.filter((m) => m.image).length ?? 0; - const line = status?.baseUrl - ? `${status.baseUrl} · ${count} model${count === 1 ? "" : "s"}${imgCount ? ` · ${imgCount} image` : ""}${status.keyPresent ? "" : " · keyless"}` - : "not set"; - return ( -
- +
+
+ Custom endpoint + setBaseUrl(e.target.value)} + placeholder="Base URL, e.g. http://localhost:11434/v1" + aria-label="Custom endpoint base URL" + data-testid="custom-baseurl-input" + disabled={busy} + autoFocus + className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" + /> +
+
+ { + setKey(e.target.value); + setKeyTouched(true); + }} + placeholder={keyPresent ? "Key set — paste to replace, leave empty to keep" : "API key — required (any value works for local servers)"} + aria-label="Custom endpoint API key" + data-testid="custom-key-input" + disabled={busy} + className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" + /> + + +
+ {needsKey && ( +

+ A key is required to enable chatting — local servers (Ollama, LM Studio) accept any value. +

+ )} + {error && ( +

+ {error} +

+ )}
); } diff --git a/components/settings/model-menu.tsx b/components/settings/model-menu.tsx index 792ae23..99d4f78 100644 --- a/components/settings/model-menu.tsx +++ b/components/settings/model-menu.tsx @@ -39,6 +39,7 @@ export function ModelMenuList({ effectiveProvider, onPick, footer, + prioritizeImage, }: { /** The full catalog — undefined while it loads. */ models: ModelCatalogEntry[] | undefined; @@ -53,6 +54,10 @@ export function ModelMenuList({ onPick: (entry: ModelCatalogEntry) => void; /** Quiet footer note under the list (context-specific). */ footer?: string; + /** Vision slot: image-capable models float to the top (still all + * selectable — a custom endpoint that doesn't report modality must + * stay pickable, so this orders rather than filters). */ + prioritizeImage?: boolean; }) { return ( <> @@ -61,6 +66,7 @@ export function ModelMenuList({ itemKey={(m) => `${m.provider}/${m.id}`} searchTerms={(m) => [m.provider, m.id, m.name]} compare={(a, b) => a.id.localeCompare(b.id)} + prioritize={prioritizeImage ? (m) => m.input.includes("image") : undefined} isSelected={(m) => m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider)} onPick={onPick} searchTestid="model-search" diff --git a/components/settings/search-menu-list.tsx b/components/settings/search-menu-list.tsx index 08a4fa6..f864e3a 100644 --- a/components/settings/search-menu-list.tsx +++ b/components/settings/search-menu-list.tsx @@ -7,7 +7,7 @@ import { fuzzyScore } from "@/src/core"; * Models and dictation languages supply only their catalog and row content. * The caller owns the shared MenuShell and dismissal behavior. */ export function SearchMenuList({ - items, itemKey, searchTerms, compare, isSelected, onPick, renderItem, + items, itemKey, searchTerms, compare, prioritize, isSelected, onPick, renderItem, searchTestid, listTestid, optionTestid, placeholder, ariaLabel, loading, empty, disabled = false, }: { @@ -15,6 +15,10 @@ export function SearchMenuList({ itemKey: (item: T) => string; searchTerms: (item: T) => string[]; compare?: (a: T, b: T) => number; + /** Optional grouping key: items it answers true for sort ahead of the + * rest, fuzzy rank still deciding within each group (a stable partition, + * never a filter — nothing is hidden). */ + prioritize?: (item: T) => boolean; isSelected: (item: T) => boolean; onPick: (item: T) => void; renderItem: (item: T) => ReactNode; @@ -41,7 +45,12 @@ export function SearchMenuList({ const results = (items ?? []) .map((item) => ({ item, score: fuzzyScore(query, ...searchTerms(item)) })) .filter((result) => result.score !== null) - .sort((a, b) => b.score! - a.score! || (compare?.(a.item, b.item) ?? 0)) + .sort( + (a, b) => + (prioritize ? Number(prioritize(b.item)) - Number(prioritize(a.item)) : 0) || + b.score! - a.score! || + (compare?.(a.item, b.item) ?? 0), + ) .map(({ item }) => item); return <> diff --git a/components/setup-cta.tsx b/components/setup-cta.tsx index 299715d..2e34caa 100644 --- a/components/setup-cta.tsx +++ b/components/setup-cta.tsx @@ -16,7 +16,7 @@ */ import { useEffect, useState } from "react"; -import { MODEL_SLOTS, type AuthKeyStatus } from "@/src/core"; +import { CUSTOM_PROVIDER, MODEL_SLOTS, type AuthKeyStatus } from "@/src/core"; import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section"; import { useModelCatalog } from "@/components/model-catalog"; import type { ModelCatalogResponse } from "@/src/core"; @@ -30,7 +30,13 @@ export function hasProviderKey( statuses: readonly AuthKeyStatus[], providers: readonly ProviderKeyInfo[] | undefined, ): boolean { - return classifyKeys(statuses, providers).providerRows.some((s) => s.present); + // A stored custom-endpoint key counts too: classifyKeys drops it from + // providerRows (it has its own dedicated row), but a connected custom + // endpoint IS a way to run inference, so it clears the zero-keys gate. + return ( + classifyKeys(statuses, providers).providerRows.some((s) => s.present) || + statuses.some((s) => s.id === CUSTOM_PROVIDER.envVar && s.present) + ); } /** Completeness is saved identity, not current provider availability. */ diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx index 4c1d9ec..3face30 100644 --- a/components/setup-dialog.tsx +++ b/components/setup-dialog.tsx @@ -66,7 +66,7 @@ const SLOT_META: Record = { const SLOT_ORDER: readonly ModelSlotName[] = ["strong", "cheap", "vision"]; /** The custom endpoint's synthetic picker entry — one more provider in the - * Stage 1 roster, connected by a base URL + optional key (POST + * Stage 1 roster, connected by a base URL + key (POST * /api/custom-provider). Not part of the /api/keys plain-key roster. */ const CUSTOM_PICKER: ProviderKeyInfo = { id: CUSTOM_PROVIDER.id, envVar: CUSTOM_PROVIDER.envVar, label: CUSTOM_PROVIDER.label }; @@ -160,7 +160,7 @@ export function SetupKeysBody({ /** The MenuShell anchor — wired by the wrapper (its triggerRef). */ menuAnchor: React.RefObject; /** The chosen provider is the custom endpoint: a base URL field joins the - * key field (the key becomes optional — keyless local servers). All three + * key field (a key is required — local servers accept any value). All three * optional so the plain-key callers (and tests) stay unchanged. */ isCustom?: boolean; baseUrl?: string; @@ -218,9 +218,9 @@ export function SetupKeysBody({ value={value} onChange={(e) => onValue(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter" && (isCustom ? baseUrl?.trim() : value.trim())) onSave(); + if (e.key === "Enter" && value.trim()) onSave(); }} - placeholder={isCustom ? "API key (optional for local servers)" : "Paste key…"} + placeholder={isCustom ? "API key — required (any value works for local servers)" : "Paste key…"} aria-label={`${provider.label} key`} data-testid="setup-key-input" disabled={saving} @@ -230,7 +230,7 @@ export function SetupKeysBody({ + {/* Disconnect rides beside Connect, exactly the stored-key rows' Delete: + quiet text, one click, no confirm — and only once there IS an + endpoint (a fresh add has nothing to delete). */} + {onDelete && baseUrl0.trim() && ( + + )}
{needsKey && (

From 695948271f014c37c85fb68f25c0286ea33ad650 Mon Sep 17 00:00:00 2001 From: Daniel Buckner Date: Tue, 27 Oct 2026 16:25:03 -0700 Subject: [PATCH 59/87] feat: user pins for local model limits (context, max output, thinking) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A local endpoint that publishes no limits — LM Studio's /models says nothing — left every model on the 8192/4096 defaults with no way to say "this one has a 131k window". Each discovered model now gets three optional pins in the endpoint editor: context window, max output tokens, thinking (auto/on/off). A pin always wins: user pin → what the endpoint published → compiled default. Blank means no opinion, so discovery keeps ruling wherever you don't pin. Pins live in customProvider.overrides keyed by model id, stored apart from the discovery snapshot: a reconnect refreshes the models and prunes the pins of models that vanished while keeping the rest. Pins are written by PATCH /api/custom-provider, which never probes — the whole point is configuring a server that isn't answering /models correctly. Connect still probes and still fails loudly when the endpoint is down. A pin under 2048 tokens is rejected on the write side and clamped up on the read path, matching the compaction-floor discipline. --- app/api/custom-provider/route.ts | 52 ++++++-- components/settings/keys-section.tsx | 175 ++++++++++++++++++++++++++- src/core/settings-resolution.ts | 7 +- src/core/settings.ts | 54 +++++++++ src/server/custom-provider.ts | 38 ++++-- src/server/settings-body.ts | 42 ++++++- tests/custom-endpoint.test.ts | 115 +++++++++++++++++- 7 files changed, 455 insertions(+), 28 deletions(-) diff --git a/app/api/custom-provider/route.ts b/app/api/custom-provider/route.ts index 05becc5..193dd2c 100644 --- a/app/api/custom-provider/route.ts +++ b/app/api/custom-provider/route.ts @@ -1,24 +1,28 @@ /** * The custom endpoint's connect surface (one provider in the Keys list). - * GET → non-secret config { baseUrl, models } + keyPresent (masked). - * POST → connect: validate + normalize the base URL, probe /models (fail - * fast), store the key, persist { baseUrl, models }, re-sync the - * provider into piModels(). Discovery lives here because connecting - * and discovering are one user action. + * GET → non-secret config { baseUrl, models, overrides } + keyPresent (masked). + * POST → connect: validate + normalize the base URL, probe /models (fail + * fast), store the key, persist { baseUrl, models }, re-sync the + * provider into piModels(). Discovery lives here because connecting + * and discovering are one user action. Stored user pins survive a + * reconnect, pruned to the ids the endpoint still advertises. + * PATCH → store per-model pins only. No probe: pins must be editable with + * the local server switched off (the case they exist for). * DELETE → disconnect: drop the stored config + the stored key and unregister - * the provider (the mirror image of POST; idempotent). + * the provider (the mirror image of POST; idempotent). */ import { deleteKey, discoverModels, effectiveSettings, keyValue, + parseCustomModelOverridesBody, readSettingsRecord, saveKey, syncCustomProvider, writeSettingsRecord, } from "@/src/server"; -import { CUSTOM_PROVIDER, normalizeBaseUrl } from "@/src/core"; +import { CUSTOM_PROVIDER, normalizeBaseUrl, sanitizeCustomModelOverrides } from "@/src/core"; export const dynamic = "force-dynamic"; @@ -27,6 +31,7 @@ export async function GET() { return Response.json({ baseUrl: cp.baseUrl, models: cp.models, + overrides: cp.overrides, keyPresent: keyValue(CUSTOM_PROVIDER.envVar) !== undefined, }); } @@ -54,11 +59,42 @@ export async function POST(request: Request) { } const record = readSettingsRecord() ?? { version: 1 }; - const next = writeSettingsRecord({ ...record, customProvider: { baseUrl: base, models: found.models } }); + // User pins are never rewritten by discovery — only pruned to what the + // endpoint still advertises (the single prune law, core/settings.ts). + const overrides = sanitizeCustomModelOverrides( + record.customProvider?.overrides, + found.models.map((m) => m.id), + ); + const next = writeSettingsRecord({ + ...record, + customProvider: { baseUrl: base, models: found.models, overrides }, + }); syncCustomProvider(next.customProvider); return Response.json({ ok: true, models: next.customProvider?.models ?? [] }); } +/** Store the per-model pins (replace-whole). Pruned against the STORED model + * list, never a probe — an offline endpoint keeps its pins editable. */ +export async function PATCH(request: Request) { + const parsed = parseCustomModelOverridesBody(await request.json().catch(() => null)); + if (!parsed.ok) return Response.json({ error: parsed.error }, { status: 400 }); + + const cp = effectiveSettings().customProvider; + if (!cp.baseUrl) return Response.json({ error: "No custom endpoint configured" }, { status: 409 }); + + const overrides = sanitizeCustomModelOverrides( + parsed.settings, + cp.models.map((m) => m.id), + ); + const record = readSettingsRecord() ?? { version: 1 }; + const next = writeSettingsRecord({ + ...record, + customProvider: { baseUrl: cp.baseUrl, models: cp.models, overrides }, + }); + syncCustomProvider(next.customProvider); + return Response.json({ ok: true, overrides: next.customProvider?.overrides ?? {} }); +} + /** Disconnect: forget the endpoint AND its key, then unregister the provider. * Deleting the section (rather than blanking baseUrl) keeps settings.json the * same shape as a never-connected install. Idempotent by construction. */ diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 8b28ad3..5aac78d 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -24,7 +24,15 @@ */ import { useEffect, useRef, useState } from "react"; -import { AUTH_KEYS, CUSTOM_PROVIDER, type AuthKeyStatus, type CustomModel } from "@/src/core"; +import { + AUTH_KEYS, + CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW, + CUSTOM_MODEL_DEFAULT_MAX_TOKENS, + CUSTOM_PROVIDER, + type AuthKeyStatus, + type CustomModel, + type CustomModelOverride, +} from "@/src/core"; import { SettingsGroup } from "./controls.tsx"; import { SubscriptionConnections } from "./subscription-connections.tsx"; import { ProviderMenuList } from "./model-menu.tsx"; @@ -49,6 +57,8 @@ interface KeysResponse { interface CustomStatus { baseUrl: string; models: CustomModel[]; + /** User pins by model id (context / max output / thinking). */ + overrides: Record; keyPresent: boolean; } @@ -402,6 +412,7 @@ export function KeysSection({ setCustom({ baseUrl: cp.baseUrl ?? "", models: cp.models ?? [], + overrides: cp.overrides ?? {}, keyPresent: Boolean(cp.keyPresent), }); } catch { @@ -500,11 +511,17 @@ export function KeysSection({ { setAddingCustom(false); await reload(); notifySettingsSaved("keys"); }} + onPinsSaved={async () => { + await reload(); + notifySettingsSaved("keys"); + }} onDelete={disconnectCustom} onCancel={() => setAddingCustom(false)} /> @@ -663,18 +680,68 @@ function CustomEndpointRow({ ); } +/** A pin row's raw input state — "" means "no pin": the endpoint's own value, + * then the compiled default, decides. `reasoning` is tri-state. */ +interface PinDraft { + context: string; + maxTokens: string; + reasoning: "" | "on" | "off"; +} + +const pinDraft = (pin: CustomModelOverride | undefined): PinDraft => ({ + context: pin?.context === undefined ? "" : String(pin.context), + maxTokens: pin?.maxTokens === undefined ? "" : String(pin.maxTokens), + reasoning: pin?.reasoning === undefined ? "" : pin.reasoning ? "on" : "off", +}); + +/** One draft row per discovered model, seeded from the stored pins. */ +function draftsFor( + models: readonly CustomModel[], + overrides: Record, +): Record { + return Object.fromEntries(models.map((m) => [m.id, pinDraft(overrides[m.id])])); +} + +/** Drafts → the PATCH payload (replace-whole): a blank field sends nothing and + * a fully blank model is absent — which IS the un-pin. */ +function pinsPayload(drafts: Record): Record { + const out: Record = {}; + for (const [id, d] of Object.entries(drafts)) { + const pin: CustomModelOverride = {}; + if (d.context.trim()) pin.context = Number(d.context); + if (d.maxTokens.trim()) pin.maxTokens = Number(d.maxTokens); + if (d.reasoning !== "") pin.reasoning = d.reasoning === "on"; + if (Object.keys(pin).length) out[id] = pin; + } + return out; +} + +/** testids must survive ids carrying slashes, colons, dots. */ +const pinTestid = (prefix: string, id: string) => `${prefix}-${id.replace(/[^a-z0-9-]/gi, "_")}`; + /** The custom endpoint's connect editor: base URL + key → POST /api/custom-provider - * (validate + discover + register). Image capability is detected automatically. */ + * (validate + discover + register), and the per-model limits form → PATCH (pins, + * no probe, so it works with the local server switched off). Image capability is + * detected automatically. */ function CustomEndpointEditor({ baseUrl0, keyPresent, + models, + overrides, onSaved, + onPinsSaved, onCancel, onDelete, }: { baseUrl0: string; keyPresent: boolean; + /** The discovered models — one limits row each. */ + models: readonly CustomModel[]; + /** Their stored user pins. */ + overrides: Record; onSaved: () => void | Promise; + /** Pins saved — the editor stays open (a form, not a connect). */ + onPinsSaved: () => void | Promise; onCancel: () => void; onDelete?: () => void | Promise; }) { @@ -683,11 +750,46 @@ function CustomEndpointEditor({ const [keyTouched, setKeyTouched] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + // The limits form: edited drafts over the stored pins, plus the last saved + // payload as the dirty baseline (the server's echo is what gets stored). + const [drafts, setDrafts] = useState>(() => draftsFor(models, overrides)); + const [baseline, setBaseline] = useState(() => JSON.stringify(pinsPayload(draftsFor(models, overrides)))); + const [pinsBusy, setPinsBusy] = useState(false); + const [pinsError, setPinsError] = useState(null); // A key is required to enable chatting; local servers accept any value. A // stored key is kept when the field is left blank. const needsKey = !keyPresent; const canConnect = Boolean(baseUrl.trim()) && (!needsKey || Boolean(key.trim())); + const draftOf = (m: CustomModel): PinDraft => drafts[m.id] ?? pinDraft(overrides[m.id]); + const setDraft = (m: CustomModel, patch: Partial) => + setDrafts((prev) => ({ ...prev, [m.id]: { ...draftOf(m), ...patch } })); + const current = Object.fromEntries(models.map((m) => [m.id, draftOf(m)])) as Record; + const dirty = JSON.stringify(pinsPayload(current)) !== baseline; + + async function savePins() { + if (pinsBusy || !dirty) return; + setPinsBusy(true); + setPinsError(null); + const res = await fetch("/api/custom-provider", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ overrides: pinsPayload(current) }), + }).catch(() => null); + setPinsBusy(false); + if (!res || !res.ok) { + const b = (await res?.json().catch(() => ({}))) as { error?: string } | undefined; + setPinsError(res ? (b?.error ?? `Save failed (${res.status})`) : "Save failed. Check your connection."); + return; + } + // The server's stored map is the truth (it floors + prunes) — echo it back. + const body = (await res.json().catch(() => null)) as { overrides?: Record } | null; + const next = draftsFor(models, body?.overrides ?? pinsPayload(current)); + setDrafts(next); + setBaseline(JSON.stringify(pinsPayload(next))); + await onPinsSaved(); + } + async function save() { if (busy || !baseUrl.trim()) return; setBusy(true); @@ -769,6 +871,75 @@ function CustomEndpointEditor({ )}

+ {models.length > 0 && ( +
+ + Model limits — blank uses the endpoint’s value, then the default + +
+ {models.map((m) => { + const d = draftOf(m); + return ( +
+ + {m.id} + + setDraft(m, { context: e.target.value })} + placeholder={String(m.context ?? CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW)} + aria-label={`${m.id} context window in tokens`} + data-testid={pinTestid("custom-pin-context", m.id)} + disabled={pinsBusy} + className="h-7 w-20 shrink-0 rounded-sm border border-border bg-background px-1.5 text-right font-mono text-[11px] text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" + /> + setDraft(m, { maxTokens: e.target.value })} + placeholder={String(m.maxTokens ?? CUSTOM_MODEL_DEFAULT_MAX_TOKENS)} + aria-label={`${m.id} max output tokens`} + data-testid={pinTestid("custom-pin-maxtokens", m.id)} + disabled={pinsBusy} + className="h-7 w-20 shrink-0 rounded-sm border border-border bg-background px-1.5 text-right font-mono text-[11px] text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" + /> + +
+ ); + })} +
+
+ + {pinsError && ( +

+ {pinsError} +

+ )} +
+
+ )} {needsKey && (

A key is required to enable chatting — local servers (Ollama, LM Studio) accept any value. diff --git a/src/core/settings-resolution.ts b/src/core/settings-resolution.ts index a7682de..ed1974b 100644 --- a/src/core/settings-resolution.ts +++ b/src/core/settings-resolution.ts @@ -38,6 +38,7 @@ import { type DictationSettings, type CompactionSettings, type CustomModel, + type CustomModelOverride, type GraphSettings, type ModelSlot, type ModelSlotName, @@ -96,8 +97,9 @@ export interface EffectiveSettings { /** Compaction (owner round 14): resolved concrete — absent record * section resolves to the factory 150k. */ compaction: Required; - /** Custom endpoint, resolved concrete: baseUrl defaults "", models default []. */ - customProvider: { baseUrl: string; models: CustomModel[] }; + /** Custom endpoint, resolved concrete: baseUrl defaults "", models and + * user pins default to empty. */ + customProvider: { baseUrl: string; models: CustomModel[]; overrides: Record }; } /** The conversation pair's chain (BYOK, owner 2026-09-22): the chat's own @@ -227,6 +229,7 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings customProvider: { baseUrl: record?.customProvider?.baseUrl ?? "", models: record?.customProvider?.models ?? [], + overrides: record?.customProvider?.overrides ?? {}, }, }; } diff --git a/src/core/settings.ts b/src/core/settings.ts index d495e07..8e5d638 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -217,6 +217,12 @@ export interface ModelsSettings { export const CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 8192; export const CUSTOM_MODEL_DEFAULT_MAX_TOKENS = 4096; +/** Floor for a user-pinned token count (owner, 2026-10-27): the read side + * clamps up, the write side rejects below — the compaction-floor discipline. + * Pins exist because endpoints that publish no limits (LM Studio) otherwise + * sit on the 8192 default with no way to say better. */ +export const CUSTOM_MODEL_PIN_FLOOR = 2_048; + /** One discovered custom model. `image`/`reasoning`/`context`/`maxTokens` are * detected from the endpoint's /models metadata; anything it omits falls back * to a default. */ @@ -233,12 +239,26 @@ export interface CustomModel { maxTokens?: number; } +/** A user pin for one custom model: it beats what the endpoint publishes + * (owner ruling — explicit user intent wins; a local server's number is + * often wrong or absent). Absent field = no pin. `reasoning` is tri-state: + * true forces thinking on, false forces it off (a server that advertises + * reasoning but rejects reasoning_effort), absent = what discovery found. */ +export interface CustomModelOverride { + context?: number; + maxTokens?: number; + reasoning?: boolean; +} + /** The single custom endpoint's stored config (delta section). */ export interface CustomProviderSettings { /** Normalized base URL (see normalizeBaseUrl). Empty/absent = not configured. */ baseUrl?: string; /** Models discovered from the endpoint (refreshed on connect). */ models?: CustomModel[]; + /** User pins keyed by model id — pure user intent, never rewritten by + * discovery, pruned to the endpoint's live ids on connect. */ + overrides?: Record; } /** Canonical base-URL form (single source): trims, requires absolute http(s), @@ -562,6 +582,38 @@ function positiveInt(value: unknown): number | undefined { return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; } +/** One pin, clamped up to the floor (clamp-never-reject on read). */ +function sanitizeCustomModelOverride(raw: unknown): CustomModelOverride | undefined { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined; + const rec = raw as Record; + const out: CustomModelOverride = {}; + const context = positiveInt(rec.context); + if (context) out.context = Math.max(CUSTOM_MODEL_PIN_FLOOR, context); + const maxTokens = positiveInt(rec.maxTokens); + if (maxTokens) out.maxTokens = Math.max(CUSTOM_MODEL_PIN_FLOOR, maxTokens); + if (typeof rec.reasoning === "boolean") out.reasoning = rec.reasoning; + return Object.keys(out).length ? out : undefined; +} + +/** THE pins map + THE prune law: well-formed entries only, ids trimmed, and + * when `liveIds` is given every entry for a model the endpoint no longer + * advertises drops. POST passes the discovered ids, PATCH the stored list. */ +export function sanitizeCustomModelOverrides( + raw: unknown, + liveIds?: Iterable, +): Record { + const out: Record = {}; + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return out; + const live = liveIds ? new Set(liveIds) : undefined; + for (const [key, value] of Object.entries(raw)) { + const id = key.trim(); + if (!id || (live && !live.has(id))) continue; + const pin = sanitizeCustomModelOverride(value); + if (pin) out[id] = pin; + } + return out; +} + /** The customProvider section: normalized base URL (invalid → dropped) and * discovered models (ids trimmed, dupes/empties dropped; image kept only when * true; token counts only as positive ints). Clamp-never-reject. */ @@ -590,6 +642,8 @@ function sanitizeCustomProvider(raw: Record): CustomProviderSet } out.models = [...byId.values()]; } + const overrides = sanitizeCustomModelOverrides(raw.overrides); + if (Object.keys(overrides).length) out.overrides = overrides; return out; } diff --git a/src/server/custom-provider.ts b/src/server/custom-provider.ts index c6015aa..b9f9b8c 100644 --- a/src/server/custom-provider.ts +++ b/src/server/custom-provider.ts @@ -15,6 +15,7 @@ import { customProviderUsable, normalizeBaseUrl, type CustomModel, + type CustomModelOverride, type CustomProviderSettings, } from "../core/settings.ts"; import { keyValue } from "./keys.ts"; @@ -36,23 +37,31 @@ const customEndpointAuth: ApiKeyAuth = { }; /** Map a discovered model to a pi-ai entry: image→input, reasoning→thinking - * levels, context/maxTokens→limits (else CUSTOM_MODEL_DEFAULT_*). baseUrl is - * stamped per-model. */ -function toPiModel(model: CustomModel, baseUrl: string): Model { + * levels, context/maxTokens→limits. Resolution ladder (owner, 2026-10-27): + * the user's pin wins, then the endpoint's own metadata, then the compiled + * default — a local server that says nothing (or says 8k) can be corrected. + * baseUrl is stamped per-model. */ +function toPiModel( + model: CustomModel, + baseUrl: string, + overrides: Record, +): Model { + const pin = overrides[model.id]; return { id: model.id, name: model.id, api: "openai-completions", provider: CUSTOM_PROVIDER.id, baseUrl, - // Detected from the endpoint's metadata (see detectReasoning). true makes - // pi-ai offer the standard levels and send reasoning_effort on the wire; - // no thinkingLevelMap → the default off/minimal/low/medium/high set. - reasoning: Boolean(model.reasoning), + // Pin wins; else detected from the endpoint's metadata (see + // detectReasoning). true makes pi-ai offer the standard levels and send + // reasoning_effort on the wire; no thinkingLevelMap → the default + // off/minimal/low/medium/high set. + reasoning: pin?.reasoning ?? Boolean(model.reasoning), input: model.image ? ["text", "image"] : ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: model.context ?? CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW, - maxTokens: model.maxTokens ?? CUSTOM_MODEL_DEFAULT_MAX_TOKENS, + contextWindow: pin?.context ?? model.context ?? CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW, + maxTokens: pin?.maxTokens ?? model.maxTokens ?? CUSTOM_MODEL_DEFAULT_MAX_TOKENS, type: "chat", }; } @@ -61,13 +70,16 @@ function toPiModel(model: CustomModel, baseUrl: string): Model { export function buildOpenAiCompatibleProvider(input: { baseUrl: string; models: readonly CustomModel[]; + /** User pins by model id (see CustomModelOverride) — absent = none. */ + overrides?: Record; }): Provider { + const overrides = input.overrides ?? {}; return createProvider({ id: CUSTOM_PROVIDER.id, name: CUSTOM_PROVIDER.label, baseUrl: input.baseUrl, auth: { apiKey: customEndpointAuth }, - models: input.models.map((m) => toPiModel(m, input.baseUrl)), + models: input.models.map((m) => toPiModel(m, input.baseUrl, overrides)), api: openAICompletionsApi(), }); } @@ -81,7 +93,11 @@ export function syncCustomProvider(cp: CustomProviderSettings | undefined): void return; } models.setProvider( - buildOpenAiCompatibleProvider({ baseUrl: normalizeBaseUrl(cp.baseUrl), models: cp.models ?? [] }), + buildOpenAiCompatibleProvider({ + baseUrl: normalizeBaseUrl(cp.baseUrl), + models: cp.models ?? [], + overrides: cp.overrides ?? {}, + }), ); } diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index a733003..d07cd6e 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -18,13 +18,14 @@ import type { AppearanceSettings, BackgroundModelRole, CompactionSettings, + CustomModelOverride, GeneralSettings, DictationSettings, GraphSettings, ModelsSettings, RoleModelOverride, } from "../core/settings.ts"; -import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts"; +import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, CUSTOM_MODEL_PIN_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts"; import { WRITABLE_TOOL_KEYS } from "../core/pending.ts"; import { isDictationLanguage } from "../core/dictation.ts"; @@ -386,6 +387,45 @@ export function parseDictationSettingsBody(body: unknown): SettingsBodyResult> { + const raw = asRecord(body); + if (!raw) return { ok: false, error: "Invalid JSON body" }; + const map = asRecord(raw.overrides); + if (!map) return { ok: false, error: "overrides must be a { modelId: { context?, maxTokens?, reasoning? } } map" }; + const out: Record = {}; + for (const [key, value] of Object.entries(map)) { + const id = key.trim(); + if (!id) return { ok: false, error: "override keys must be non-empty model ids" }; + const entry = asRecord(value); + if (!entry) return { ok: false, error: `override for ${id} must be an object` }; + const pin: CustomModelOverride = {}; + for (const field of ["context", "maxTokens"] as const) { + const v = entry[field]; + if (v === undefined) continue; + if (typeof v !== "number" || !Number.isInteger(v) || v < CUSTOM_MODEL_PIN_FLOOR) { + return { + ok: false, + error: `${field} for ${id} must be a whole number of tokens at least ${CUSTOM_MODEL_PIN_FLOOR}`, + }; + } + pin[field] = v; + } + if (entry.reasoning !== undefined) { + if (typeof entry.reasoning !== "boolean") return { ok: false, error: `reasoning for ${id} must be true or false` }; + pin.reasoning = entry.reasoning; + } + out[id] = pin; + } + return { ok: true, settings: out }; +} + /** The section→parser map for PUT /api/settings/[section]. The custom endpoint * is NOT a section — it connects via POST /api/custom-provider. */ export const SETTINGS_SECTIONS = { diff --git a/tests/custom-endpoint.test.ts b/tests/custom-endpoint.test.ts index d1d4845..8b64253 100644 --- a/tests/custom-endpoint.test.ts +++ b/tests/custom-endpoint.test.ts @@ -2,8 +2,9 @@ * Tests for the custom OpenAI-compatible endpoint (custom-endpoint wave). * Covers the pure core (normalizeBaseUrl, customProviderUsable, the sanitizer, * resolution), the deep server module (provider build + live sync + model - * discovery incl. image-modality and reasoning detection), and the catalog - * federation row. No network: the discovery probe runs against a stubbed fetch. + * discovery incl. image-modality, reasoning detection and the per-model user + * pins), the pins' strict write-side parser, and the catalog federation row. + * No network: the discovery probe runs against a stubbed fetch. */ import assert from "node:assert/strict"; @@ -11,11 +12,14 @@ import { after, describe, it } from "node:test"; import { CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW, CUSTOM_MODEL_DEFAULT_MAX_TOKENS, + CUSTOM_MODEL_PIN_FLOOR, customProviderUsable, normalizeBaseUrl, + sanitizeCustomModelOverrides, sanitizeUserSettings, } from "../src/core/settings.ts"; import { resolveSettings } from "../src/core/settings-resolution.ts"; +import { parseCustomModelOverridesBody } from "../src/server/settings-body.ts"; import { CUSTOM_PROVIDER } from "../src/core/auth-keys.ts"; import { buildOpenAiCompatibleProvider, @@ -84,10 +88,86 @@ describe("sanitizeUserSettings — customProvider", () => { }); }); -// ─── resolution ────────────────────────────────────────────────────────────── +// ─── per-model pins: the read side + THE prune law ─────────────────────────── +describe("sanitizeUserSettings / sanitizeCustomModelOverrides — pins", () => { + it("keeps well-formed pins, clamps token counts up to the floor, keeps reasoning:false", () => { + const out = sanitizeUserSettings({ + customProvider: { + baseUrl: "https://h/v1", + overrides: { + llama: { context: 131072, maxTokens: 8192, reasoning: true }, + qwen: { context: 10 }, // under the floor → clamped up (clamp-never-reject on read) + quiet: { reasoning: false }, // a real pin, not an absent one + junk: { context: "big", maxTokens: -1, reasoning: "yes" }, // every field junk + " ": { context: 4096 }, // no usable id + nope: "not-an-object", + }, + }, + }); + assert.deepEqual(out?.customProvider?.overrides, { + llama: { context: 131072, maxTokens: 8192, reasoning: true }, + qwen: { context: CUSTOM_MODEL_PIN_FLOOR }, + quiet: { reasoning: false }, + }); + }); + it("prunes pins for models the endpoint no longer advertises — the single prune law", () => { + const pins = { a: { context: 32768 }, b: { context: 32768 } }; + assert.deepEqual(Object.keys(sanitizeCustomModelOverrides(pins, ["a"])), ["a"]); + assert.deepEqual( + Object.keys(sanitizeCustomModelOverrides(pins)), + ["a", "b"], + "no liveIds → no prune (that is the routes' call, not the record sanitizer's)", + ); + assert.deepEqual(sanitizeCustomModelOverrides("nope"), {}, "a non-object map resolves empty"); + }); + it("drops an invalid overrides section from the record entirely", () => { + const out = sanitizeUserSettings({ customProvider: { baseUrl: "https://h/v1", overrides: "nope" } }); + assert.equal(out?.customProvider?.overrides, undefined); + }); +}); + +// ─── pins: the strict write side (PATCH) ────────────────────────────────── +describe("parseCustomModelOverridesBody — the strict write side", () => { + it("accepts whole counts at or above the floor and the tri-state reasoning pin", () => { + const r = parseCustomModelOverridesBody({ + overrides: { + a: { context: CUSTOM_MODEL_PIN_FLOOR, reasoning: false }, + b: { maxTokens: 8192 }, + c: {}, // an empty pin is legal — the sanitizer drops it on write + }, + }); + assert.ok(r.ok); + assert.deepEqual(r.settings, { + a: { context: CUSTOM_MODEL_PIN_FLOOR, reasoning: false }, + b: { maxTokens: 8192 }, + c: {}, + }); + }); + it("rejects a count under the floor, naming the floor", () => { + const r = parseCustomModelOverridesBody({ overrides: { a: { context: CUSTOM_MODEL_PIN_FLOOR - 1 } } }); + assert.equal(r.ok, false); + if (!r.ok) assert.ok(r.error.includes(String(CUSTOM_MODEL_PIN_FLOOR)), r.error); + }); + it("rejects a fractional count, a non-boolean reasoning, and a missing overrides map", () => { + assert.equal(parseCustomModelOverridesBody({ overrides: { a: { context: 4096.5 } } }).ok, false); + assert.equal(parseCustomModelOverridesBody({ overrides: { a: { reasoning: "on" } } }).ok, false); + assert.equal(parseCustomModelOverridesBody({ overrides: { a: "nope" } }).ok, false); + assert.equal(parseCustomModelOverridesBody({}).ok, false); + assert.equal(parseCustomModelOverridesBody(null).ok, false); + }); +}); + +// ─── resolution ────────────────────────────────────────────────────────── describe("resolveSettings — customProvider", () => { it("resolves an absent section to concrete defaults", () => { - assert.deepEqual(resolveSettings(null).customProvider, { baseUrl: "", models: [] }); + assert.deepEqual(resolveSettings(null).customProvider, { baseUrl: "", models: [], overrides: {} }); + }); + it("carries stored pins through to the concrete view", () => { + const resolved = resolveSettings({ + version: 1, + customProvider: { baseUrl: "http://h/v1", models: [{ id: "a" }], overrides: { a: { context: 32768 } } }, + }); + assert.deepEqual(resolved.customProvider.overrides, { a: { context: 32768 } }); }); }); @@ -124,6 +204,33 @@ describe("buildOpenAiCompatibleProvider / syncCustomProvider", () => { assert.equal(big!.maxTokens, 393216); }); + it("a user pin beats the endpoint, which beats the compiled default", () => { + const provider = buildOpenAiCompatibleProvider({ + baseUrl: "http://h/v1", + models: [{ id: "pinned", context: 32768, maxTokens: 4096 }, { id: "detected", context: 65536 }, { id: "bare" }], + overrides: { pinned: { context: 131072, maxTokens: 16384 } }, + }); + const [pinned, detected, bare] = provider.getModels(); + assert.equal(pinned!.contextWindow, 131072, "the pin wins over the endpoint's own number"); + assert.equal(pinned!.maxTokens, 16384); + assert.equal(detected!.contextWindow, 65536, "no pin → the endpoint's value"); + assert.equal(detected!.maxTokens, CUSTOM_MODEL_DEFAULT_MAX_TOKENS, "endpoint silent → the compiled default"); + assert.equal(bare!.contextWindow, CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW); + }); + + it("the thinking pin is tri-state: false beats a detected-true endpoint, true beats silence", () => { + const provider = buildOpenAiCompatibleProvider({ + baseUrl: "http://h/v1", + models: [{ id: "liar", reasoning: true }, { id: "silent" }], + overrides: { liar: { reasoning: false }, silent: { reasoning: true } }, + }); + const [liar, silent] = provider.getModels(); + assert.equal(liar!.reasoning, false, "a server that advertises reasoning but rejects it can be pinned off"); + assert.deepEqual(getSupportedThinkingLevels(liar!), ["off"]); + assert.equal(silent!.reasoning, true, "LM Studio publishes nothing; the user says otherwise"); + assert.deepEqual(getSupportedThinkingLevels(silent!), ["off", "minimal", "low", "medium", "high"]); + }); + it("gives a reasoning-capable model the standard thinking levels (others stay \"off\")", () => { const provider = buildOpenAiCompatibleProvider({ baseUrl: "http://h/v1", From f84bef1f911221984cb27e87f60832ca9f6dc29d Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 04:42:46 +0200 Subject: [PATCH 60/87] Add declarative custom providers through native Pi AI --- config.ts | 3 + docs/custom-providers.md | 159 ++++++++++++++++++ src/agent/custom-providers.ts | 68 ++++++++ src/agent/models.ts | 36 ++++- src/core/contracts/models.ts | 6 +- src/core/custom-providers.ts | 146 +++++++++++++++++ src/server/custom-providers.ts | 90 +++++++++++ src/server/keys.ts | 6 + src/server/model-catalog.ts | 4 + src/store/custom-providers.ts | 12 ++ tests/custom-providers.test.ts | 284 +++++++++++++++++++++++++++++++++ 11 files changed, 811 insertions(+), 3 deletions(-) create mode 100644 docs/custom-providers.md create mode 100644 src/agent/custom-providers.ts create mode 100644 src/core/custom-providers.ts create mode 100644 src/server/custom-providers.ts create mode 100644 src/store/custom-providers.ts create mode 100644 tests/custom-providers.test.ts diff --git a/config.ts b/config.ts index 860b571..daf0b4e 100644 --- a/config.ts +++ b/config.ts @@ -111,6 +111,9 @@ export const GRAPH_FORMAT_FILE = path.join(PATHS.graph, "format.json"); */ export const SETTINGS_FILE = path.join(DATA_ROOT, "settings.json"); +/** Explicit custom-provider/model definitions; never read the user's Pi profile. */ +export const CUSTOM_MODELS_FILE = path.join(DATA_ROOT, "models.json"); + /** * User prompt-override files (settings-storage wave, 2026-09-19): the * per-segment system-prompt overrides mirroring the SOURCE layout diff --git a/docs/custom-providers.md b/docs/custom-providers.md new file mode 100644 index 0000000..bd6901d --- /dev/null +++ b/docs/custom-providers.md @@ -0,0 +1,159 @@ +# Custom model providers + +LearnHarness can register several independently named custom providers using its +existing Pi AI runtime. This is configuration wiring, not another agent runtime: +Pi owns the model protocols, request conversion, streaming, tools, thinking and +usage accounting. There is no additional dependency on Pi coding-agent. + +## Configuration location + +Create `models.json` in the LearnHarness app-data directory (the directory +containing `settings.json`; `LH_DATA_DIR` relocates it). LearnHarness does not read +`~/.pi/agent/models.json`, install a model server, or edit this file for you. +Custom-provider setup UI is not included yet. Once configured, models appear in +the existing model selectors when their credential/header references resolve. + +The file uses Pi's `providers` / `models` structure. For example: + +```json +{ + "providers": { + "my-local-server": { + "baseUrl": "http://127.0.0.1:11434/v1", + "api": "openai-completions", + "apiKey": "unused-local-key", + "compat": { + "supportsDeveloperRole": false, + "supportsReasoningEffort": false, + "maxTokensField": "max_tokens" + }, + "models": [ + { + "id": "your-installed-model", + "name": "My local model", + "contextWindow": 8192, + "maxTokens": 1024, + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + } + } + ] + }, + "my-gateway": { + "baseUrl": "https://your-gateway.example/v1", + "api": "openai-completions", + "apiKey": "$MY_GATEWAY_API_KEY", + "headers": { "X-Tenant": "${MY_GATEWAY_TENANT}" }, + "models": [ + { + "id": "your-gateway-model-id", + "contextWindow": 32768, + "maxTokens": 4096, + "cost": { + "input": 1, + "output": 3, + "cacheRead": 0, + "cacheWrite": 0 + } + } + ] + } + } +} +``` + +**Replace all example IDs, limits, prices and compatibility flags with the actual +server/model configuration.** These numbers are examples, not recommendations or +automatically detected capabilities. Rates are USD per million tokens. Explicit +zero rates are allowed; LearnHarness does not assume that custom gateways are +free. Verify the endpoint's billing independently. + +Use the exact API base URL your server documents. LearnHarness does not append +`/v1`, discover models, infer capabilities from names, or download models. Ollama, +vLLM, LM Studio, 9router and other gateways work only to the extent that their +configured endpoint implements the selected Pi protocol and compatibility flags. +For a keyless OpenAI-compatible local server, supply an explicit nonempty dummy +`apiKey` as above: the native Pi OpenAI client requires a key. Omitting `apiKey` +leaves a provider unavailable rather than implying unauthenticated access. + +## Supported fields + +Provider fields: `name`, `baseUrl`, `api`, `apiKey`, `headers`, `authHeader`, +`compat`, and the explicit `models` array. Each model requires `id`, +`contextWindow`, `maxTokens`, and `cost` with all four base rates. Unlike Pi +coding-agent's defaults, LearnHarness never invents limits or prices. + +Optional Pi model fields are `name`, `api`, `baseUrl`, `reasoning`, `input`, +`thinkingLevelMap`, `inputLimits`, `promptCache`, `samplingParams`, +`samplingParamsByThinkingLevel`, `headers`, and `compat`. Cost tiers and Pi's +exported compatibility schemas are supported. A model's `api` and `baseUrl` +override its provider's values; model headers override provider headers. Names +default to model IDs, `reasoning` defaults to false, and `input` defaults to +`["text"]`. Declare image input only when the server/model supports it. Existing +onboarding and vision-slot requirements are unchanged. + +The native chat transports available in the pinned Pi AI 1.4.0 dependency are: +`openai-completions`, `openai-responses`, `anthropic-messages`, +`google-generative-ai`, `azure-openai-responses`, `google-vertex`, +`bedrock-converse-stream`, `mistral-conversations`, `openai-codex-responses`, and +`pi-messages`. Protocol availability does **not** establish account access, +OAuth support, cloud credentials, subscription permission, or vendor approval. +This feature adds no login flows. In particular, an Anthropic Messages endpoint +is not a Claude Agent SDK or Claude Code runtime. + +Use new provider IDs; built-in and subscription-route IDs cannot be replaced. +Provider IDs must be nonempty and cannot contain `:` (the provider/model +coordinate separator). Model IDs must be unique within a provider. Built-in +provider overrides, `modelOverrides`, OAuth definitions, executable plugins, and +automatic discovery are outside this phase. + +## Credentials and trust + +API keys and header values accept literals, `$NAME`, and `${NAME}`. References +use LearnHarness's existing key lookup: environment first, then the existing +flat `auth.json` file, respecting the fresh-user environment-ignore setting. +There is no auth migration or new secret file format. Prefer references over +putting secrets into `models.json`; protect any file containing literal secrets. +Unknown variable references keep the provider unavailable. All required header +references, including model-specific ones, must resolve before a provider becomes +available. An empty key does not configure a provider. + +Pi's `$$` and `$!` literal escapes are supported. `!command` credential/header +values are rejected, never executed; resolved environment/file values are data, +not commands. Optional `authHeader: true` adds `Authorization: Bearer ` in +addition to the selected native transport's normal authentication behavior. + +Only trusted people should edit this file: its endpoints receive model prompts, +attachments, tool results and configured credentials. Only HTTP(S) URLs are +accepted, without embedded URL credentials or fragments. This is not an +arbitrary-endpoint discovery or config-editing API and does not add a network +sandbox, endpoint reachability checks, or vendor policy exemptions. + +## Reloading and failures + +Changes take effect on the next catalog, settings-validation, or inference +operation without restarting. Credential/header reference changes are also +picked up through the existing key lookup. Every upstream model operation +revalidates the same saved provider/model ID against the current Pi catalog, +including later requests inside a long-running agent/tool loop. It uses the +current endpoint, model metadata and resolved credentials. A removed model, +invalid configuration or missing credential blocks the next operation, without +falling back. An HTTP operation already in flight retains its own native Pi +setup and is not implicitly cancelled by a file edit. + +Deleting the file means no custom providers. Invalid JSON, invalid metadata, +reserved IDs, unsupported configuration, or an unreadable file disables the +custom routes, **not** built-in providers. The file accepts Pi's line comments +and trailing commas. `GET /api/models` includes an optional sanitized +`customProvidersError` diagnostic; rendering it in setup/settings UI is deferred. +Correcting the file restores valid providers on the next operation. + +Saved selections and thinking preferences are not erased when a provider/model +is removed or becomes unavailable. New work using an unavailable selection fails +rather than falling back to another model. Select another configured model to +repair the slot. Custom-provider removal does not rewrite historical records; +like existing API-key models, historical cost estimates use the current catalog +and become unknown when the relevant model definition is unavailable. diff --git a/src/agent/custom-providers.ts b/src/agent/custom-providers.ts new file mode 100644 index 0000000..3966ea1 --- /dev/null +++ b/src/agent/custom-providers.ts @@ -0,0 +1,68 @@ +/** Native Pi providers assembled from validated configuration. No IO or registry + * here: the server supplies key lookup, and Pi owns model dispatch/streaming. */ +import { createProvider, type Provider, type ProviderStreams } from "@earendil-works/pi-ai"; +import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy"; +import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy"; +import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy"; +import { openAICodexResponsesApi } from "@earendil-works/pi-ai/api/openai-codex-responses.lazy"; +import { azureOpenAIResponsesApi } from "@earendil-works/pi-ai/api/azure-openai-responses.lazy"; +import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy"; +import { googleVertexApi } from "@earendil-works/pi-ai/api/google-vertex.lazy"; +import { bedrockConverseStreamApi } from "@earendil-works/pi-ai/api/bedrock-converse-stream.lazy"; +import { mistralConversationsApi } from "@earendil-works/pi-ai/api/mistral-conversations.lazy"; +import { piMessagesApi } from "@earendil-works/pi-ai/api/pi-messages.lazy"; +import { resolveCustomValue, type CustomProviderConfig } from "../core/custom-providers.ts"; + +const apis: Record = { + "anthropic-messages": anthropicMessagesApi(), + "openai-completions": openAICompletionsApi(), + "openai-responses": openAIResponsesApi(), + "openai-codex-responses": openAICodexResponsesApi(), + "azure-openai-responses": azureOpenAIResponsesApi(), + "google-generative-ai": googleGenerativeAIApi(), + "google-vertex": googleVertexApi(), + "bedrock-converse-stream": bedrockConverseStreamApi(), + "mistral-conversations": mistralConversationsApi(), + "pi-messages": piMessagesApi(), +}; +export const customProviderApis: ReadonlySet = new Set(Object.keys(apis)); + +function resolveHeaders(headers: Record | undefined, lookup: (name: string) => string | undefined): Record | undefined { + const result: Record = {}; + for (const [name, value] of Object.entries(headers ?? {})) { + const resolved = resolveCustomValue(value, lookup); + if (resolved === undefined) return undefined; + result[name] = resolved; + } + return result; +} +export function customProviderAuth(config: CustomProviderConfig, lookup: (name: string) => string | undefined) { + const key = config.definition.apiKey === undefined ? undefined : resolveCustomValue(config.definition.apiKey, lookup); + const headers = resolveHeaders(config.definition.headers, lookup); + if (!key || !headers) return undefined; + // A model header is part of that model's request configuration. Unresolved + // references must not silently disappear from a request. + for (const model of config.models) if (!resolveHeaders(model.headers, lookup)) return undefined; + if (config.definition.authHeader) { + for (const name of Object.keys(headers)) if (name.toLowerCase() === "authorization") delete headers[name]; + headers.Authorization = `Bearer ${key}`; + } + return { apiKey: key, headers }; +} + +export function createCustomProvider(config: CustomProviderConfig, lookup: (name: string) => string | undefined): Provider { + const auth = customProviderAuth(config, lookup); + return createProvider({ + id: config.id, + name: config.definition.name ?? config.id, + baseUrl: config.definition.baseUrl, + models: config.models.map((model) => ({ ...model, headers: resolveHeaders(model.headers, lookup) })), + auth: { apiKey: { + name: "API key", + check: async () => auth ? { type: "api_key", source: "custom provider configuration" } : undefined, + resolve: async () => auth ? { auth, source: "custom provider configuration" } : undefined, + } }, + // Direct native implementations: no copied stream or request conversion. + api: apis, + }); +} diff --git a/src/agent/models.ts b/src/agent/models.ts index 75c0b7b..001f5c1 100644 --- a/src/agent/models.ts +++ b/src/agent/models.ts @@ -5,9 +5,11 @@ import { type Api, type Model, type Models, + type MutableModels, type StreamFunction, } from "@earendil-works/pi-ai"; import { builtinProviders } from "@earendil-works/pi-ai/providers/all"; +import { lazyStream } from "@earendil-works/pi-ai/api/lazy"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { type ModelRole } from "../../config.ts"; import { DEFAULT_THINKING_ORDER } from "../core/settings.ts"; @@ -49,6 +51,23 @@ export interface PiEngine { export type { ThinkingLevel }; let collection: ReturnType | undefined; +interface ProviderSource { + sync(models: MutableModels): void; + isCustom(id: string): boolean; +} +let providerSource: ProviderSource | undefined; + +/** Configuration IO is injected by the server; the agent never imports stores. */ +export function configureProviderSource(source: ProviderSource): void { + // Server module re-evaluation can replace the source while this collection + // survives. Retire only the previous source's routes, not built-ins. + if (collection && providerSource) { + for (const provider of collection.getProviders()) { + if (providerSource.isCustom(provider.id)) collection.deleteProvider(provider.id); + } + } + providerSource = source; +} /** The process-wide pi-ai Models collection — EVERY builtin provider's * catalog (model slots wave, 2026-09-20): engines and the picker resolve @@ -63,6 +82,7 @@ export function piModels() { collection = createModels({ identity: APP_IDENTITY }); for (const provider of builtinProviders()) collection.setProvider(provider); } + providerSource?.sync(collection); return collection; } @@ -111,6 +131,9 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | ( const level = spec.thinkingLevel ? clampThinkingLevel(model, spec.thinkingLevel) : defaultThinkingLevel(model); + // A lesson's agent loop can stay alive through many tool calls. Remember + // only that this is a custom route, never its configuration for that run. + const custom = providerSource?.isCustom(spec.provider) ?? false; return { model, level, @@ -118,9 +141,20 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | ( // `reasoning` (undefined = off on pi-ai's scale); the clamp above keeps // the pin honest even when config or settings drift off the map. streamFn: (requestModel, context, options) => { + const requestOptions = { ...options, reasoning: level === "off" ? undefined : level }; + if (custom) { + // Revalidate before EVERY upstream model operation, including later + // tool-loop steps. Pi resolves auth and clamps the requested thinking + // against the current native model. Its in-flight operation retains + // its own setup; a subsequent operation uses the reloaded provider. + const current = piModels(); + const currentModel = current.getModel(spec.provider, spec.id); + if (!currentModel) return lazyStream(requestModel, async () => { throw new Error("The selected custom model is unavailable."); }); + return current.streamSimple(currentModel, context, requestOptions); + } const purpose = typeof background === "function" ? background : () => background; const stream = spec.provider === "chatgpt" ? chatGPTStream(purpose, lifecycle) : nativeId ? nativeSubscriptionStream(models.getProvider(nativeId)!, purpose, lifecycle) : models.streamSimple.bind(models); - return stream(requestModel, context, { ...options, reasoning: level === "off" ? undefined : level }); + return stream(requestModel, context, requestOptions); }, }; } diff --git a/src/core/contracts/models.ts b/src/core/contracts/models.ts index 2819bb5..7b9e9e7 100644 --- a/src/core/contracts/models.ts +++ b/src/core/contracts/models.ts @@ -73,13 +73,15 @@ export interface ModelCatalogResponse { * are simply absent (the Keys page is the discovery surface, same * discipline as the search engines). */ providers: CatalogProvider[]; + /** Sanitized configuration diagnostic; built-in catalogs remain available. */ + customProvidersError?: string; } /** One row of the providers surface. */ export interface CatalogProvider { id: string; - /** The env var whose presence federates this provider in. */ + /** The gating env var; empty for subscription and file-configured routes. */ envVar: string; - /** Whether that var is currently present (env or auth file). */ + /** Whether credentials are present (and custom references resolve). */ keyPresent: boolean; } \ No newline at end of file diff --git a/src/core/custom-providers.ts b/src/core/custom-providers.ts new file mode 100644 index 0000000..9aaed27 --- /dev/null +++ b/src/core/custom-providers.ts @@ -0,0 +1,146 @@ +/** Declarative custom-provider configuration. The envelope/normalization follows + * Pi coding-agent 1.4.0's model-config/provider-composer (MIT, Mario Zechner). + * Pi AI owns the shared model/compatibility schemas and the runtime models. + * Deliberate differences: new provider IDs only, explicit limits/prices, + * and no OAuth, extensions, overrides, or executable credential commands. + * + * Adapted portions: Copyright (c) Mario Zechner. Permission is hereby granted, + * free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, + * merge, publish, distribute, sublicense, and/or sell copies of the Software, + * and to permit persons to whom the Software is furnished to do so, subject to + * the following conditions: The above copyright notice and this permission + * notice shall be included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. */ +import { Type, type Static } from "typebox"; +import { Compile } from "typebox/compile"; +import type { Api, Model } from "@earendil-works/pi-ai"; +import { ProviderCompatSchema } from "@earendil-works/pi-ai/providers/compat-schema"; +import { ModelCostSchema, ModelInputLimitsSchema, ModelInputModalitySchema, ModelPromptCacheSchema, ThinkingLevelMapSchema } from "@earendil-works/pi-ai/providers/model-schema"; + +const Headers = Type.Record(Type.String(), Type.String()); +const Sampling = Type.Record(Type.String(), Type.Unknown()); +const ModelSchema = Type.Object({ + id: Type.String({ minLength: 1 }), + name: Type.Optional(Type.String({ minLength: 1 })), + api: Type.Optional(Type.String({ minLength: 1 })), + baseUrl: Type.Optional(Type.String({ minLength: 1 })), + reasoning: Type.Optional(Type.Boolean()), + thinkingLevelMap: Type.Optional(ThinkingLevelMapSchema), + input: Type.Optional(Type.Array(ModelInputModalitySchema)), + inputLimits: Type.Optional(ModelInputLimitsSchema), + cost: ModelCostSchema, + promptCache: Type.Optional(ModelPromptCacheSchema), + contextWindow: Type.Number({ exclusiveMinimum: 0 }), + maxTokens: Type.Number({ exclusiveMinimum: 0 }), + samplingParams: Type.Optional(Sampling), + samplingParamsByThinkingLevel: Type.Optional(Type.Object(Object.fromEntries( + ["off", "minimal", "low", "medium", "high", "xhigh", "max"].map((level) => [level, Type.Optional(Sampling)]), + ))), + headers: Type.Optional(Headers), + compat: Type.Optional(ProviderCompatSchema), +}, { additionalProperties: false }); +const ProviderSchema = Type.Object({ + name: Type.Optional(Type.String({ minLength: 1 })), + baseUrl: Type.Optional(Type.String({ minLength: 1 })), + api: Type.Optional(Type.String({ minLength: 1 })), + apiKey: Type.Optional(Type.String({ minLength: 1 })), + headers: Type.Optional(Headers), + compat: Type.Optional(ProviderCompatSchema), + authHeader: Type.Optional(Type.Boolean()), + models: Type.Array(ModelSchema), +}, { additionalProperties: false }); +const ConfigSchema = Type.Object({ + $schema: Type.Optional(Type.String()), + providers: Type.Record(Type.String(), ProviderSchema), +}, { additionalProperties: false }); +const validator = Compile(ConfigSchema); +export type CustomProviderDefinition = Static; +export interface CustomProviderConfig { + id: string; + definition: CustomProviderDefinition; + models: Model[]; +} + +/** Error messages describe fields only: never include file contents or values. */ +export class CustomProviderConfigError extends Error {} +function invalid(message: string): never { throw new CustomProviderConfigError(message); } + +/** Pi-style values, but never execute commands. Escapes are processed once; + * a literal/variable's resolved value beginning with ! is still just data. */ +export function resolveCustomValue(value: string, lookup: (name: string) => string | undefined): string | undefined { + if (value.startsWith("!")) invalid("Credential commands are not supported in models.json."); + let missing = false; + const resolved = value.replace(/\$\$|\$!|\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, braced: string | undefined, plain: string | undefined) => { + if (match === "$$") return "$"; + if (match === "$!") return "!"; + const found = lookup(braced ?? plain!); + if (found === undefined) missing = true; + return found ?? ""; + }); + return missing ? undefined : resolved; +} + +function endpoint(value: string | undefined): string { + if (!value) invalid("Each custom model needs an api and baseUrl, at provider or model level."); + try { + const url = new URL(value); + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.hash) throw new Error(); + } catch { invalid("Custom baseUrl must be an HTTP(S) URL without embedded credentials or a fragment."); } + return value; +} +function compat(base: Model["compat"], override: Model["compat"]): Model["compat"] { + if (!base && !override) return undefined; + const merged = { ...base, ...override } as Record; + for (const name of ["openRouterRouting", "vercelGatewayRouting", "chatTemplateKwargs", "chatTemplateArgs"]) { + const a = (base as Record | undefined)?.[name]; + const b = (override as Record | undefined)?.[name]; + if (a || b) merged[name] = { ...(a as object | undefined), ...(b as object | undefined) }; + } + return merged as Model["compat"]; +} + +export function parseCustomProviders(text: string, reserved: ReadonlySet, supportedApis: ReadonlySet): CustomProviderConfig[] { + let raw: unknown; + // Match Pi's line comments/trailing commas without touching JSON strings. + const json = text.replace(/^\uFEFF/, "") + .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (match) => match[0] === '"' ? match : "") + .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (match, tail: string | undefined) => tail ?? (match[0] === '"' ? match : "")); + try { raw = JSON.parse(json); } + catch { return invalid("models.json is not valid JSON."); } + if (!validator.Check(raw)) invalid("Invalid models.json structure: check provider/model fields and explicit contextWindow, maxTokens and cost."); + return Object.entries(raw.providers).map(([id, definition]) => { + if (!id.trim() || id.includes(":")) invalid("Custom provider IDs must be nonempty and must not contain a colon."); + if (reserved.has(id)) invalid("Custom provider IDs must not replace built-in or subscription providers."); + for (const value of [definition.apiKey, ...Object.values(definition.headers ?? {}), ...definition.models.flatMap((m) => Object.values(m.headers ?? {}))]) { + if (value !== undefined) resolveCustomValue(value, () => "validation"); + } + const ids = new Set(); + const models = definition.models.map((model): Model => { + if (ids.has(model.id)) invalid("Duplicate model IDs are not supported within a custom provider."); + ids.add(model.id); + const api = model.api ?? definition.api; + if (!api || !supportedApis.has(api)) invalid("A custom model specifies a missing or unsupported Pi API."); + const rates = [model.cost, ...(model.cost.tiers ?? [])]; + if (![model.contextWindow, model.maxTokens].every(Number.isFinite) || rates.some((rate) => Object.values(rate).some((n) => typeof n === "number" && (!Number.isFinite(n) || n < 0)))) invalid("Custom model limits and costs must be finite; limits must be positive and costs nonnegative."); + return { + ...model, + provider: id, + api, + baseUrl: endpoint(model.baseUrl ?? definition.baseUrl), + name: model.name ?? model.id, + reasoning: model.reasoning ?? false, + input: model.input ?? ["text"], + compat: compat(definition.compat, model.compat), + }; + }); + return { id, definition, models }; + }); +} diff --git a/src/server/custom-providers.ts b/src/server/custom-providers.ts new file mode 100644 index 0000000..c597403 --- /dev/null +++ b/src/server/custom-providers.ts @@ -0,0 +1,90 @@ +/** Configuration composition only. Pi's Models collection is the one runtime + * registry. A failed reload removes custom routes, never alters built-ins. */ +import type { MutableModels } from "@earendil-works/pi-ai"; +import { builtinProviders } from "@earendil-works/pi-ai/providers/all"; +import { createCustomProvider, customProviderApis, customProviderAuth } from "../agent/custom-providers.ts"; +import { configureProviderSource } from "../agent/models.ts"; +import { parseCustomProviders, resolveCustomValue, CustomProviderConfigError, type CustomProviderConfig } from "../core/custom-providers.ts"; +import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts"; +import { readCustomProvidersFile } from "../store/custom-providers.ts"; + +const reserved = new Set([...builtinProviders().map((p) => p.id), "chatgpt", ...NATIVE_SUBSCRIPTIONS.map((p) => p.logicalId)]); + +/** Instance-local so every Next server module graph registers into its own Pi + * collection. No process-global registry or personal Pi config is consulted. */ +export class CustomProviderSource { + private text?: string; + private fingerprint?: string; + private configs: CustomProviderConfig[] = []; + private registered = new Set(); + private ready = new Map(); + private error?: string; + + constructor( + private readonly read: typeof readCustomProvidersFile, + private readonly key: (name: string) => string | undefined, + ) {} + + sync(models: MutableModels): void { + try { + const record = this.read(); + if (record.error) throw new CustomProviderConfigError(record.error); + if (record.text !== this.text || this.error) { + this.configs = parseCustomProviders(record.text!, reserved, customProviderApis); + this.text = record.text; + this.fingerprint = undefined; + } + // Resolve only referenced variables through the app's existing key seam. + // Changes in credentials/headers trigger a new immutable provider snapshot + // even when the configuration file itself has not changed. + const values = new Map(); + for (const config of this.configs) { + for (const template of [config.definition.apiKey, ...Object.values(config.definition.headers ?? {}), ...config.models.flatMap((m) => Object.values(m.headers ?? {}))]) { + if (template !== undefined) resolveCustomValue(template, (name) => { + const value = this.key(name); + values.set(name, value); + return value; + }); + } + } + const fingerprint = JSON.stringify([...values]); + if (this.fingerprint === fingerprint) return; + const lookup = (name: string) => values.get(name); + const providers = this.configs.map((config) => createCustomProvider(config, lookup)); + const ready = new Map(this.configs.map((config) => [config.id, customProviderAuth(config, lookup) !== undefined])); + // Everything validates/builds before the synchronous publication begins. + for (const id of this.registered) models.deleteProvider(id); + this.registered = new Set(providers.map((p) => p.id)); + for (const provider of providers) models.setProvider(provider); + this.ready = ready; + this.fingerprint = fingerprint; + this.error = undefined; + } catch (error) { + for (const id of this.registered) models.deleteProvider(id); + this.registered.clear(); + this.ready.clear(); + this.fingerprint = undefined; + this.error = error instanceof CustomProviderConfigError ? error.message : "Unable to configure custom providers from models.json."; + } + } + + configured(provider: string): boolean | undefined { return this.ready.get(provider); } + providers(): { id: string; envVar: string; keyPresent: boolean }[] { + return [...this.ready].map(([id, keyPresent]) => ({ id, envVar: "", keyPresent })); + } + getError(): string | undefined { return this.error; } +} + +let source: CustomProviderSource | undefined; +export function installCustomProviderSource(key: (name: string) => string | undefined): void { + const next = new CustomProviderSource(readCustomProvidersFile, key); + configureProviderSource({ + sync: (models) => next.sync(models), + isCustom: (id) => next.configured(id) !== undefined, + }); + source = next; +} +/** Call piModels() before these reads to synchronize the snapshot. */ +export function customProviderRows() { return source?.providers() ?? []; } +export function customProviderConfigured(provider: string): boolean | undefined { return source?.configured(provider); } +export function customProvidersError(): string | undefined { return source?.getError(); } diff --git a/src/server/keys.ts b/src/server/keys.ts index 706de52..9a74b5c 100644 --- a/src/server/keys.ts +++ b/src/server/keys.ts @@ -53,6 +53,8 @@ import { } from "../core/auth-keys.ts"; import { authStore } from "../store/auth.ts"; import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts"; +import { piModels } from "../agent/models.ts"; +import { customProviderConfigured, installCustomProviderSource } from "./custom-providers.ts"; // The once-per-process boot record — globalThis anchor (HMR re-imports the // module; the snapshot must not re-run and misread fill-gapped vars as real @@ -201,6 +203,9 @@ export function keyValue(id: string): string | undefined { * (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take * no key at all: nothing to gate, present by definition. */ export function providerKeyPresent(provider: string): boolean { + piModels(); // Synchronize hand-edited custom configuration before admission. + const custom = customProviderConfigured(provider); + if (custom !== undefined) return custom; if (isSubscriptionProvider(provider)) return subscriptionConnected(provider); const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider); return info ? keyValue(info.envVar) !== undefined : true; @@ -244,3 +249,4 @@ export function deleteKey(id: string): AuthKeyStatus { // Reflect the file into the env at first import (idempotent; the module is // imported by every route/handler path that touches keys). initAuthEnv(); +installCustomProviderSource(keyValue); diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index 11fbe0f..f0d8e8d 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -20,6 +20,7 @@ import { clampThinkingLevel, getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { piModels } from "../agent/models.ts"; +import { customProviderRows, customProvidersError } from "./custom-providers.ts"; import { chatGPTConnected, chatGPTModel, chatGPTModels, refreshChatGPTModels } from "../agent/chatgpt-bridge.ts"; import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts"; import { nativeSubscriptionId, nativeSubscriptionConnected, nativeSubscriptionModel, nativeSubscriptionModels, refreshSubscriptionModels } from "../agent/subscription-bridge.ts"; @@ -135,6 +136,7 @@ export function catalogSlots(): SlotPins { * with keyPresent read env-wins through the keys seam, fresh per call — * saving a key immediately federates the provider into the picker. */ export function catalogProviders(): CatalogProvider[] { + piModels(); return AUTH_PROVIDER_KEYS.map((p) => ({ id: p.id, envVar: p.envVar, @@ -142,6 +144,7 @@ export function catalogProviders(): CatalogProvider[] { })).concat([ { id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() }, ...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })), + ...customProviderRows(), ]).sort((a, b) => a.id.localeCompare(b.id)); } @@ -211,6 +214,7 @@ export function modelCatalog(): ModelCatalogResponse { rolePins: catalogRolePins(), slots: catalogSlots(), providers: catalogProviders(), + ...(customProvidersError() ? { customProvidersError: customProvidersError() } : {}), }; } diff --git a/src/store/custom-providers.ts b/src/store/custom-providers.ts new file mode 100644 index 0000000..2245823 --- /dev/null +++ b/src/store/custom-providers.ts @@ -0,0 +1,12 @@ +/** Read-only adapter. Configuration editing/UI is deliberately a later phase. */ +import fs from "node:fs"; +import { CUSTOM_MODELS_FILE } from "../../config.ts"; + +export function readCustomProvidersFile(): { text?: string; error?: string } { + try { return { text: fs.readFileSync(CUSTOM_MODELS_FILE, "utf8") }; } + catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT" + ? { text: '{"providers":{}}' } + : { error: "Unable to read models.json." }; + } +} diff --git a/tests/custom-providers.test.ts b/tests/custom-providers.test.ts new file mode 100644 index 0000000..2cdd415 --- /dev/null +++ b/tests/custom-providers.test.ts @@ -0,0 +1,284 @@ +import assert from "node:assert/strict"; +import { after, describe, it } from "node:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createModels, normalizeContext } from "@earendil-works/pi-ai"; +import { parseCustomProviders, resolveCustomValue } from "../src/core/custom-providers.ts"; +import { createCustomProvider, customProviderApis } from "../src/agent/custom-providers.ts"; + +const zero = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; +function model(id = "chat", extra = {}) { + return { id, contextWindow: 8192, maxTokens: 1024, cost: zero, ...extra }; +} +function config(extra = {}, models = [model()]) { + return { providers: { gateway: { baseUrl: "http://example.invalid/custom/v1", api: "openai-completions", apiKey: "dummy", models, ...extra } } }; +} +function parse(value: unknown) { return parseCustomProviders(JSON.stringify(value), new Set(["openai", "chatgpt"]), customProviderApis); } + +const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-providers-")); +process.env.LH_DATA_DIR = temp; +process.env.LH_AUTH_FILE = path.join(temp, "auth.json"); +process.env.LH_IGNORE_ENV_KEYS = "1"; +fs.writeFileSync(process.env.LH_AUTH_FILE, JSON.stringify({ GW_KEY: "first-key", GW_HEADER: "first-header" })); +const { CUSTOM_MODELS_FILE, SETTINGS_FILE } = await import("../config.ts"); +const { modelCatalog, validateModelsSettings, clampModelsLevels, catalogContextLimits } = await import("../src/server/model-catalog.ts"); +const { piModels, piEngineForSpec } = await import("../src/agent/models.ts"); +const { providerKeyPresent, keyValue } = await import("../src/server/keys.ts"); +const { authStore } = await import("../src/store/auth.ts"); +const { CustomProviderSource, installCustomProviderSource } = await import("../src/server/custom-providers.ts"); +const { callCost } = await import("../src/server/pricing.ts"); +after(() => fs.rmSync(temp, { recursive: true, force: true })); + +function save(value: unknown) { fs.writeFileSync(CUSTOM_MODELS_FILE, JSON.stringify(value)); } +function sseText(text = "ok") { + const chunks = [ + { choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }] }, + { choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 } }, + ]; + return new Response(chunks.map((chunk) => `data: ${JSON.stringify({ id: "test", object: "chat.completion.chunk", created: 1, model: "chat", ...chunk })}\n\n`).join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } }); +} + +describe("declarative custom-provider configuration", () => { + it("uses Pi metadata and provider/model inheritance without rewriting base URLs", () => { + const [parsed] = parse(config({ compat: { supportsStore: false, chatTemplateKwargs: { a: true } } }, [model("named", { + name: "Named model", reasoning: true, input: ["text", "image"], thinkingLevelMap: { high: "high", xhigh: null }, + compat: { maxTokensField: "max_tokens", chatTemplateKwargs: { b: false } }, + samplingParams: { temperature: 0.5 }, samplingParamsByThinkingLevel: { high: { top_p: 0.9 } }, + })])); + assert.equal(parsed!.models[0]!.baseUrl, "http://example.invalid/custom/v1"); + assert.equal(parsed!.models[0]!.provider, "gateway"); + assert.equal(parsed!.models[0]!.name, "Named model"); + assert.deepEqual(parsed!.models[0]!.input, ["text", "image"]); + assert.deepEqual(parsed!.models[0]!.compat, { supportsStore: false, maxTokensField: "max_tokens", chatTemplateKwargs: { a: true, b: false } }); + assert.deepEqual(parsed!.models[0]!.samplingParamsByThinkingLevel, { high: { top_p: 0.9 } }); + }); + it("accepts Pi line comments, trailing commas and BOM without corrupting strings", () => { + const text = `\uFEFF${JSON.stringify(config()).replace('"providers":', '// comment\n"providers":').replace(/}$/, ',}')}`; + assert.equal(parseCustomProviders(text, new Set(), customProviderApis)[0]!.models[0]!.baseUrl, "http://example.invalid/custom/v1"); + }); + it("registers multiple providers and mixed native APIs without fixed backend IDs", () => { + const value = config(); + const raw = { providers: { ...value.providers, other: { ...value.providers.gateway, models: [model("chat"), model("anthropic", { api: "anthropic-messages", baseUrl: "https://example.invalid/anthropic" })] } } }; + const models = createModels(); + for (const entry of parse(raw)) models.setProvider(createCustomProvider(entry, () => undefined)); + assert.equal(models.getProviders().length, 2); + assert.equal(models.getModel("other", "anthropic")!.api, "anthropic-messages"); + assert.equal(models.getModel("other", "anthropic")!.baseUrl, "https://example.invalid/anthropic"); + }); + it("requires explicit finite limits and nonnegative costs, instead of guessing", () => { + for (const field of ["contextWindow", "maxTokens", "cost"]) { + const m: Record = model(); delete m[field]; + assert.throws(() => parse(config({}, [m as ReturnType]))); + } + assert.throws(() => parse(config({}, [model("chat", { contextWindow: -1 })]))); + assert.throws(() => parse(config({}, [model("chat", { cost: { ...zero, output: -1 } })]))); + assert.throws(() => parseCustomProviders(JSON.stringify(config()).replace('"contextWindow":8192', '"contextWindow":1e999'), new Set(), customProviderApis)); + }); + it("rejects collisions, duplicate IDs, unsupported protocols, executable fields and invalid URLs", () => { + for (const id of ["openai", "chatgpt", "", "has:colon"]) assert.throws(() => parse({ providers: { [id]: config().providers.gateway } })); + assert.throws(() => parse(config({}, [model(), model()]))); + assert.throws(() => parse(config({ api: "unknown-api" }))); + assert.throws(() => parse(config({ oauth: "radius" }))); + assert.throws(() => parse(config({ modelOverrides: {} }))); + assert.throws(() => parse(config({ apiKey: "!touch /never-execute" }))); + assert.throws(() => parse(config({ headers: { "x-key": "!secret-command" } }))); + assert.throws(() => parse(config({}, [model("chat", { headers: { Authorization: "!command" } })]))); + for (const baseUrl of ["file:///tmp/model", "https://secret@example.invalid", "http://example.invalid/#fragment"]) assert.throws(() => parse(config({ baseUrl }))); + }); + it("matches Pi value interpolation and escapes, without recursive expansion or commands", () => { + const lookup = (key: string) => ({ KEY: "secret", DATA: "!never-executed" })[key as "KEY"]; + assert.equal(resolveCustomValue("Bearer ${KEY}/$KEY", lookup), "Bearer secret/secret"); + assert.equal(resolveCustomValue("$$KEY $!literal", lookup), "$KEY !literal"); + assert.equal(resolveCustomValue("$MISSING", lookup), undefined); + assert.equal(resolveCustomValue("$DATA", lookup), "!never-executed"); + }); +}); + +describe("custom providers in the existing application wiring", () => { + it("is empty before configuration and never creates a configuration file", () => { + assert.equal(fs.existsSync(CUSTOM_MODELS_FILE), false); + assert.equal(modelCatalog().customProvidersError, undefined); + assert.equal(fs.existsSync(CUSTOM_MODELS_FILE), false); + }); + it("projects into existing pickers, validation, context limits and Pi pricing", async () => { + save(config({ apiKey: "$GW_KEY", headers: { "x-gateway": "$GW_HEADER" } }, [model("chat", { cost: { ...zero, input: 2, output: 4 } })])); + const catalog = modelCatalog(); + assert.ok(catalog.providers.find((p) => p.id === "gateway")?.keyPresent); + assert.ok(catalog.models.some((m) => m.provider === "gateway" && m.id === "chat")); + assert.equal(JSON.stringify(catalog).includes("first-key"), false); + assert.equal(JSON.stringify(catalog).includes("first-header"), false); + assert.equal(validateModelsSettings({ strong: { model: { provider: "gateway", id: "chat" } } }).ok, true); + assert.equal(catalogContextLimits().get("gateway:chat"), 8192); + assert.ok(Math.abs(callCost({ inputTokens: 100, outputTokens: 50 }, "gateway:chat")! - 0.0004) < 1e-12); + assert.equal(providerKeyPresent("gateway"), true); + assert.equal((await piModels().getAuth("gateway"))!.auth.apiKey, "first-key"); + }); + it("uses current app key precedence and marks unresolved credentials/headers unavailable", () => { + process.env.LH_IGNORE_ENV_KEYS = "0"; + process.env.GW_KEY = "real-env-key"; + try { assert.equal(keyValue("GW_KEY"), "real-env-key"); } + finally { process.env.LH_IGNORE_ENV_KEYS = "1"; delete process.env.GW_KEY; } + save(config({ apiKey: "$MISSING_KEY" })); + assert.equal(providerKeyPresent("gateway"), false); + assert.equal(modelCatalog().models.some((m) => m.provider === "gateway"), false); + save(config({ headers: { "x-required": "$MISSING_HEADER" } })); + assert.equal(providerKeyPresent("gateway"), false); + save(config({ apiKey: undefined })); + assert.equal(providerKeyPresent("gateway"), false, "Pi-style keyless endpoints need an explicit dummy key"); + }); + it("revalidates every request of one engine while leaving an in-flight HTTP operation alone", async () => { + save(config({ apiKey: "$GW_KEY", headers: { "x-gateway": "$GW_HEADER" }, compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false, maxTokensField: "max_tokens" } })); + const engine = piEngineForSpec({ provider: "gateway", id: "chat" }); + const context = normalizeContext({ messages: [{ role: "user", content: "hello", timestamp: 1 }] }); + let started!: () => void, release!: (response: Response) => void; + const requestStarted = new Promise((resolve) => { started = resolve; }); + const firstResponse = new Promise((resolve) => { release = resolve; }); + const requests: { url: string; headers: Headers; body: Record }[] = []; + const original = globalThis.fetch; + globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + requests.push({ url: request.url, headers: request.headers, body: await request.json() as Record }); + if (requests.length === 1) { started(); return firstResponse; } + return sseText("héllo 🌍"); + }; + const pending = engine.streamFn(engine.model, context); + try { + await requestStarted; + authStore.setAuthVar("GW_KEY", "second-key"); + authStore.setAuthVar("GW_HEADER", "second-header"); + save(config({ baseUrl: "https://new.example.invalid/inference/v1", apiKey: "$GW_KEY", headers: { "x-gateway": "$GW_HEADER" }, compat: { supportsStore: false, maxTokensField: "max_tokens" } }, [model("chat", { maxTokens: 512 })])); + const second = await engine.streamFn(engine.model, context).result(); + assert.equal(second.stopReason, "stop"); + assert.deepEqual(second.content, [{ type: "text", text: "héllo 🌍" }]); + assert.equal(second.usage.input, 10); + assert.equal(second.usage.output, 2); + // Credential-only edits must also take effect on this same engine. + authStore.setAuthVar("GW_KEY", "third-key"); + assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "stop"); + // None of these cases may dispatch using the engine's old model/key. + save(config({ apiKey: "$MISSING_KEY" })); + assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error"); + save(config({}, [model("other-model")])); + assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error"); + fs.writeFileSync(CUSTOM_MODELS_FILE, "invalid configuration"); + assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error"); + fs.rmSync(CUSTOM_MODELS_FILE); + assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error"); + release(sseText("original request finished")); + const first = await pending.result(); + assert.equal(first.stopReason, "stop"); + assert.deepEqual(first.content, [{ type: "text", text: "original request finished" }]); + } finally { + release(sseText()); + await pending.result(); + globalThis.fetch = original; + } + assert.equal(requests.length, 3, "unavailable configurations must not cause additional transport calls"); + assert.equal(requests[0]!.url, "http://example.invalid/custom/v1/chat/completions"); + assert.equal(requests[0]!.headers.get("authorization"), "Bearer first-key"); + assert.equal(requests[0]!.headers.get("x-gateway"), "first-header"); + assert.equal(requests[1]!.url, "https://new.example.invalid/inference/v1/chat/completions"); + assert.equal(requests[1]!.headers.get("authorization"), "Bearer second-key"); + assert.equal(requests[1]!.headers.get("x-gateway"), "second-header"); + assert.equal(requests[2]!.headers.get("authorization"), "Bearer third-key"); + assert.equal(requests[0]!.body.model, "chat"); + assert.equal("store" in requests[0]!.body, false); + assert.equal(requests[0]!.body.max_tokens, 1024); + assert.equal(requests[1]!.body.max_tokens, 512, "current Pi model metadata, not the stale engine model, controls the request"); + }); + it("disables custom routes on malformed edits without removing built-ins or saved choices", () => { + fs.writeFileSync(SETTINGS_FILE, JSON.stringify({ version: 1, models: { strong: { model: { provider: "gateway", id: "chat" }, thinkingLevel: "high" } } })); + const before = fs.readFileSync(SETTINGS_FILE, "utf8"); + fs.writeFileSync(CUSTOM_MODELS_FILE, '{ secret-token-do-not-log'); + const catalog = modelCatalog(); + assert.ok(catalog.customProvidersError); + assert.equal(catalog.customProvidersError!.includes("secret-token"), false); + assert.equal(piModels().getProvider("gateway"), undefined); + assert.ok(piModels().getProvider("openai")); + assert.deepEqual(catalog.slots.strong!.model, { provider: "gateway", id: "chat" }); + const stored = { strong: { model: { provider: "gateway", id: "chat" }, thinkingLevel: "high" as const } }; + const repaired = { strong: { ...stored.strong, thinkingLevel: "max" as const }, cheap: { model: { provider: "openai", id: piModels().getModels("openai")[0]!.id } } }; + assert.equal(validateModelsSettings(repaired, stored).ok, true, "an unavailable custom pin must not prevent independent slot repair"); + assert.equal(clampModelsLevels(repaired).strong!.thinkingLevel, "max", "preserve thinking while custom metadata is unavailable"); + assert.equal(validateModelsSettings({ cheap: stored.strong }, stored).ok, false, "unchanged identity preservation is same-slot only"); + assert.equal(fs.readFileSync(SETTINGS_FILE, "utf8"), before); + assert.throws(() => piEngineForSpec({ provider: "gateway", id: "chat" }), /catalog has no model/); + save(config()); + assert.equal(modelCatalog().customProvidersError, undefined); + assert.ok(piModels().getProvider("gateway")); + fs.rmSync(CUSTOM_MODELS_FILE); + assert.equal(modelCatalog().models.some((m) => m.provider === "gateway"), false); + assert.equal(modelCatalog().customProvidersError, undefined); + assert.equal(fs.readFileSync(SETTINGS_FILE, "utf8"), before); + }); + it("refreshes credentials without a file edit, preserving native header precedence and snapshots", async () => { + const values = new Map([["KEY", "one"], ["HEADER", "first"]]); + const source = new CustomProviderSource(() => ({ text: JSON.stringify(config({ apiKey: "$KEY", authHeader: true, headers: { "x-shared": "$HEADER" } }, [model("chat", { headers: { "x-shared": "model-${HEADER}" } })])) }), (name) => values.get(name)); + const models = createModels(); + source.sync(models); + const captured = createModels(); + captured.setProvider(models.getProvider("gateway")!); + values.set("KEY", "two"); values.set("HEADER", "second"); + source.sync(models); + const current = (await models.getAuth(models.getModel("gateway", "chat")!))!.auth; + const previous = (await captured.getAuth(captured.getModel("gateway", "chat")!))!.auth; + assert.equal(current.apiKey, "two"); + assert.equal(current.headers!["x-shared"], "model-second"); + assert.equal(current.headers!.Authorization, "Bearer two"); + assert.equal(previous.apiKey, "one"); + assert.equal(previous.headers!["x-shared"], "model-first"); + }); + it("rejects built-in and subscription-route collisions without replacing their providers", () => { + for (const id of ["openai", "github-copilot", "chatgpt", "github-copilot-subscription", "xai-subscription"]) { + const existing = piModels().getProvider("openai")!; + const models = createModels(); models.setProvider(existing); + const source = new CustomProviderSource(() => ({ text: JSON.stringify({ providers: { [id]: config().providers.gateway } }) }), () => undefined); + source.sync(models); + assert.ok(source.getError()); + assert.equal(models.getProvider("openai"), existing); + assert.equal(models.getProviders().length, 1); + } + }); + it("fails closed across partial publication and recovers without leaking internal errors", () => { + const text = JSON.stringify({ providers: { ...config().providers, second: config().providers.gateway } }); + const source = new CustomProviderSource(() => ({ text }), () => undefined); + const models = createModels(); + const builtin = piModels().getProvider("openai")!; + models.setProvider(builtin); + const publish = models.setProvider.bind(models); + models.setProvider = (provider) => { + if (provider.id === "second") throw new Error("private-value-do-not-log"); + publish(provider); + }; + source.sync(models); + assert.equal(models.getProvider("gateway"), undefined); + assert.equal(models.getProvider("second"), undefined); + assert.equal(models.getProvider("openai"), builtin); + assert.equal(source.getError()!.includes("private-value"), false); + models.setProvider = publish; + source.sync(models); + assert.ok(models.getProvider("gateway")); + assert.ok(models.getProvider("second")); + assert.equal(source.getError(), undefined); + }); + it("retires old custom routes when server configuration bootstrap is reinstalled", () => { + save(config()); + assert.ok(piModels().getProvider("gateway")); + fs.rmSync(CUSTOM_MODELS_FILE); + installCustomProviderSource(keyValue); + assert.equal(piModels().getProvider("gateway"), undefined); + assert.ok(piModels().getProvider("openai")); + }); + it("treats unreadable config as unavailable and recovers without a restart", () => { + let unreadable = true; + const source = new CustomProviderSource(() => unreadable ? { error: "Unable to read models.json." } : { text: JSON.stringify(config()) }, () => undefined); + const models = createModels(); + source.sync(models); + assert.equal(source.getError(), "Unable to read models.json."); + unreadable = false; + source.sync(models); + assert.ok(models.getProvider("gateway")); + assert.equal(source.getError(), undefined); + }); +}); From fb7341d82b2176ded84990e073d572f34e9d3170 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 04:54:57 +0200 Subject: [PATCH 61/87] Keep native Bedrock implementation in standalone traces --- scripts/smoke-provider-runtime.mjs | 21 +++++++++++++-------- src/agent/custom-providers.ts | 7 +++++-- tests/provider-runtime-payload.test.ts | 5 +++-- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/scripts/smoke-provider-runtime.mjs b/scripts/smoke-provider-runtime.mjs index 47c12c6..7cafc1d 100644 --- a/scripts/smoke-provider-runtime.mjs +++ b/scripts/smoke-provider-runtime.mjs @@ -1,7 +1,7 @@ /** - * OFFLINE native Pi Google initialization gate (no generation or provider calls). - * Unlike /api/models, this traverses Pi's lazy adapter and constructs GoogleGenAI. - * The real onPayload hook throws a unique sentinel BEFORE generateContentStream. + * OFFLINE native Pi Google/Bedrock initialization gate (no generation or provider calls). + * Unlike /api/models, this traverses Pi's lazy adapters and constructs real SDK clients. + * The real onPayload hook throws a unique sentinel BEFORE SDK request dispatch. * All file modules must resolve inside the payload, including nested dependencies. * * node scripts/smoke-provider-runtime.mjs [--standalone

] [--runtime ] @@ -81,17 +81,22 @@ async function probe(standalone) { const entry = pathToFileURL(path.join(root, "node_modules/@earendil-works/pi-ai/dist/index.js")); const { createModels } = await import(entry.href); const models = createModels(); - for (const [name, factory] of [["google", "googleProvider"], ["opencode", "opencodeProvider"], ["opencode-go", "opencodeGoProvider"]]) { + for (const [name, factory, api, label] of [ + ["google", "googleProvider", "google-generative-ai", "Google"], + ["opencode", "opencodeProvider", "google-generative-ai", "Google"], + ["opencode-go", "opencodeGoProvider", "google-generative-ai", "Google"], + ["amazon-bedrock", "amazonBedrockProvider", "bedrock-converse-stream", "Bedrock"], + ]) { const provider = (await import(new URL(`providers/${name}.js`, entry).href))[factory](); models.setProvider(provider); - const model = provider.getModels().find((candidate) => candidate.api === "google-generative-ai"); - assert.ok(model, `${name}: missing Google-backed catalog model`); + const model = provider.getModels().find((candidate) => candidate.api === api); + assert.ok(model, `${name}: missing ${api} catalog model`); const sentinel = `LH_OFFLINE_ON_PAYLOAD_${randomUUID()}`; let reached = 0; const result = await models.streamSimple(model, { messages: [{ role: "user", content: "Offline initialization only", timestamp: 0 }] }, { apiKey: "dummy-offline-provider-gate-not-a-key", onPayload(payload, selected) { - assert.equal(selected.api, "google-generative-ai"); + assert.equal(selected.api, api); assert.ok(payload && typeof payload === "object"); reached++; throw new Error(sentinel); @@ -101,7 +106,7 @@ async function probe(standalone) { assert.equal(result.stopReason, "error"); assert.equal(result.errorMessage, sentinel, `${name}: unexpected initialization error`); noNetwork(); - console.log(`[smoke-provider-runtime] ${name}: native lazy Google adapter reached onPayload; generation stopped`); + console.log(`[smoke-provider-runtime] ${name}: native lazy ${label} adapter reached onPayload; generation stopped`); } // Exercise the public lazy OAuth surface offline. toAuth evaluates the // real flow module and its imports but neither logs in nor refreshes. diff --git a/src/agent/custom-providers.ts b/src/agent/custom-providers.ts index 3966ea1..5c82773 100644 --- a/src/agent/custom-providers.ts +++ b/src/agent/custom-providers.ts @@ -8,7 +8,7 @@ import { openAICodexResponsesApi } from "@earendil-works/pi-ai/api/openai-codex- import { azureOpenAIResponsesApi } from "@earendil-works/pi-ai/api/azure-openai-responses.lazy"; import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy"; import { googleVertexApi } from "@earendil-works/pi-ai/api/google-vertex.lazy"; -import { bedrockConverseStreamApi } from "@earendil-works/pi-ai/api/bedrock-converse-stream.lazy"; +import { lazyApi } from "@earendil-works/pi-ai/api/lazy"; import { mistralConversationsApi } from "@earendil-works/pi-ai/api/mistral-conversations.lazy"; import { piMessagesApi } from "@earendil-works/pi-ai/api/pi-messages.lazy"; import { resolveCustomValue, type CustomProviderConfig } from "../core/custom-providers.ts"; @@ -21,7 +21,10 @@ const apis: Record = { "azure-openai-responses": azureOpenAIResponsesApi(), "google-generative-ai": googleGenerativeAIApi(), "google-vertex": googleVertexApi(), - "bedrock-converse-stream": bedrockConverseStreamApi(), + // Pi's Bedrock wrapper intentionally hides its Node-only import from + // bundlers. Give this server's standalone tracer a literal public import; + // Pi still owns both lazy forwarding and the unchanged native transport. + "bedrock-converse-stream": lazyApi(() => import("@earendil-works/pi-ai/api/bedrock-converse-stream")), "mistral-conversations": mistralConversationsApi(), "pi-messages": piMessagesApi(), }; diff --git a/tests/provider-runtime-payload.test.ts b/tests/provider-runtime-payload.test.ts index dec3213..85c9b8b 100644 --- a/tests/provider-runtime-payload.test.ts +++ b/tests/provider-runtime-payload.test.ts @@ -1,4 +1,4 @@ -/** Real installed Pi/Google SDK only; no provider stubs, keys, or generation. */ +/** Real installed Pi/Google/Bedrock SDKs only; no provider stubs, keys, or generation. */ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; @@ -12,11 +12,12 @@ const checkout = fileURLToPath(new URL("..", import.meta.url)); const run = (payload: string) => spawnSync(process.execPath, [script, "--standalone", payload], { encoding: "utf8", timeout: 40_000 }); describe("offline native Pi Google initialization gate", () => { - it("reaches the sentinel for direct Google and Google-backed OpenCode adapters using the real SDK", () => { + it("reaches the sentinel for Google, Google-backed OpenCode and Bedrock adapters using the real SDKs", () => { // This is an unbundled positive control, NOT proof of standalone completeness. const result = run(checkout); assert.equal(result.status, 0, String(result.stderr)); for (const provider of ["google", "opencode", "opencode-go"]) assert.ok(String(result.stdout).includes(`${provider}: native lazy Google adapter reached onPayload`)); + assert.ok(String(result.stdout).includes("amazon-bedrock: native lazy Bedrock adapter reached onPayload")); assert.match(String(result.stdout), /zero network attempts/); }); it("rejects a missing Pi entry instead of treating any error as success", () => { From 313a3c88a349cc68c46670b63549635b57db2943 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 05:07:55 +0200 Subject: [PATCH 62/87] Recompute custom thinking preferences for each model request --- src/agent/models.ts | 28 ++++++++++++---------- tests/custom-providers.test.ts | 43 +++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/src/agent/models.ts b/src/agent/models.ts index 001f5c1..69815cf 100644 --- a/src/agent/models.ts +++ b/src/agent/models.ts @@ -43,8 +43,8 @@ export const APP_IDENTITY = Object.freeze({ export interface PiEngine { model: Model; streamFn: StreamFunction; - /** The resolved (config-or-settings, CLAMPED) thinking level — - * observability + tests; the streamFn pins it anyway. */ + /** The initially resolved (config-or-settings, CLAMPED) thinking level. + * Custom streams re-resolve the original preference per operation. */ level?: ThinkingLevel; } @@ -128,8 +128,9 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | ( // UNSET level → the baked default order (first entry the model // supports); SET level → pi's native nearest-clamp (model swaps keep // the user's intent and adapt honestly). - const level = spec.thinkingLevel - ? clampThinkingLevel(model, spec.thinkingLevel) + const thinkingPreference = spec.thinkingLevel; + const level = thinkingPreference + ? clampThinkingLevel(model, thinkingPreference) : defaultThinkingLevel(model); // A lesson's agent loop can stay alive through many tool calls. Remember // only that this is a custom route, never its configuration for that run. @@ -137,21 +138,24 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | ( return { model, level, - // bound streamSimple: the agent loop forwards state.thinkingLevel as - // `reasoning` (undefined = off on pi-ai's scale); the clamp above keeps - // the pin honest even when config or settings drift off the map. + // The agent loop forwards state.thinkingLevel as `reasoning` (undefined + // = off on pi-ai's scale). Built-ins keep the factory clamp; custom + // requests re-resolve thinking against their current metadata below. streamFn: (requestModel, context, options) => { - const requestOptions = { ...options, reasoning: level === "off" ? undefined : level }; if (custom) { // Revalidate before EVERY upstream model operation, including later - // tool-loop steps. Pi resolves auth and clamps the requested thinking - // against the current native model. Its in-flight operation retains - // its own setup; a subsequent operation uses the reloaded provider. + // tool-loop steps. Resolve thinking from the ORIGINAL preference (or + // existing default order), not a previous metadata snapshot's clamp. + // Pi retains the setup of an operation already in flight. const current = piModels(); const currentModel = current.getModel(spec.provider, spec.id); if (!currentModel) return lazyStream(requestModel, async () => { throw new Error("The selected custom model is unavailable."); }); - return current.streamSimple(currentModel, context, requestOptions); + const currentLevel = thinkingPreference + ? clampThinkingLevel(currentModel, thinkingPreference) + : defaultThinkingLevel(currentModel); + return current.streamSimple(currentModel, context, { ...options, reasoning: currentLevel === "off" ? undefined : currentLevel }); } + const requestOptions = { ...options, reasoning: level === "off" ? undefined : level }; const purpose = typeof background === "function" ? background : () => background; const stream = spec.provider === "chatgpt" ? chatGPTStream(purpose, lifecycle) : nativeId ? nativeSubscriptionStream(models.getProvider(nativeId)!, purpose, lifecycle) : models.streamSimple.bind(models); return stream(requestModel, context, requestOptions); diff --git a/tests/custom-providers.test.ts b/tests/custom-providers.test.ts index 2cdd415..71e9aa3 100644 --- a/tests/custom-providers.test.ts +++ b/tests/custom-providers.test.ts @@ -3,7 +3,8 @@ import { after, describe, it } from "node:test"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { createModels, normalizeContext } from "@earendil-works/pi-ai"; +import { createModels, normalizeContext, type SimpleStreamOptions } from "@earendil-works/pi-ai"; +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { parseCustomProviders, resolveCustomValue } from "../src/core/custom-providers.ts"; import { createCustomProvider, customProviderApis } from "../src/agent/custom-providers.ts"; @@ -187,6 +188,46 @@ describe("custom providers in the existing application wiring", () => { assert.equal(requests[0]!.body.max_tokens, 1024); assert.equal(requests[1]!.body.max_tokens, 512, "current Pi model metadata, not the stale engine model, controls the request"); }); + const thinking = (...levels: ThinkingLevel[]) => ({ + reasoning: true, + // Pi treats omitted levels through high as supported; mark every + // unselected level explicitly unsupported for these narrow fixtures. + thinkingLevelMap: { off: null, minimal: null, low: null, medium: null, high: null, xhigh: null, max: null, ...Object.fromEntries(levels.map((level) => [level, level])) }, + }); + const thinkingCases: { name: string; preference?: ThinkingLevel; initial: object; updated: object; before: ThinkingLevel; after: ThinkingLevel }[] = [ + { name: "recovers saved max after high-only metadata expands", preference: "max", initial: thinking("high"), updated: thinking("high", "max"), before: "high", after: "max" }, + { name: "recovers saved max after reasoning-off metadata expands", preference: "max", initial: { reasoning: false }, updated: thinking("high", "max"), before: "off", after: "max" }, + { name: "selects the existing medium-first default after reasoning becomes supported", initial: { reasoning: false }, updated: thinking("low", "medium", "high"), before: "off", after: "medium" }, + { name: "reselects the existing low-before-high default against updated metadata", initial: thinking("high"), updated: thinking("low", "high"), before: "high", after: "low" }, + { name: "clamps saved max when supported thinking contracts", preference: "max", initial: thinking("high", "max"), updated: thinking("high"), before: "max", after: "high" }, + { name: "omits reasoning when the model loses thinking support", preference: "max", initial: thinking("high", "max"), updated: { reasoning: false }, before: "max", after: "off" }, + ]; + for (const scenario of thinkingCases) it(scenario.name, async () => { + const definition = (metadata: object) => config({ compat: { supportsReasoningEffort: true, thinkingFormat: "openai" } }, [model("chat", metadata)]); + save(definition(scenario.initial)); + const engine = piEngineForSpec({ provider: "gateway", id: "chat", thinkingLevel: scenario.preference }); + assert.equal(engine.level, scenario.before); + const requests: Record[] = []; + const original = globalThis.fetch; + globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + requests.push(await request.json() as Record); + return sseText(); + }; + try { + const context = normalizeContext({ messages: [{ role: "user", content: "hello", timestamp: 1 }] }); + // A long-lived agent still forwards its initial thinking setting. + const options: SimpleStreamOptions = { reasoning: engine.level === "off" ? undefined : engine.level }; + assert.equal((await engine.streamFn(engine.model, context, options).result()).stopReason, "stop"); + save(definition(scenario.updated)); + assert.equal((await engine.streamFn(engine.model, context, options).result()).stopReason, "stop"); + } finally { globalThis.fetch = original; } + assert.equal(requests.length, 2); + for (const [index, level] of [scenario.before, scenario.after].entries()) { + assert.equal(requests[index]!.reasoning_effort, level === "off" ? undefined : level); + if (level === "off") assert.equal("reasoning_effort" in requests[index]!, false); + } + }); it("disables custom routes on malformed edits without removing built-ins or saved choices", () => { fs.writeFileSync(SETTINGS_FILE, JSON.stringify({ version: 1, models: { strong: { model: { provider: "gateway", id: "chat" }, thinkingLevel: "high" } } })); const before = fs.readFileSync(SETTINGS_FILE, "utf8"); From 3dd52110bbcbcba81645453ce0a8fdc230594c47 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 05:24:27 +0200 Subject: [PATCH 63/87] docs: add Discord link to README navigation --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0aa7706..11bbbd1 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ Download · How it works · Documentation · - Issues + Issues · + Discord

LearnHarness uses AI agents to plan and teach lessons from your current understanding to your goal. It builds a detailed picture of what you understand and how you reason. That picture is updated as you learn. From 82f0b59355efd425db6e2201c395f8267334fda0 Mon Sep 17 00:00:00 2001 From: Hadrien Cornier Date: Tue, 27 Oct 2026 22:57:35 -0500 Subject: [PATCH 64/87] Keep the question dock aligned with the native pending call --- components/chat.tsx | 3 ++- src/core/pending.ts | 12 ++++++---- tests/pending-authority.test.ts | 40 +++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 tests/pending-authority.test.ts diff --git a/components/chat.tsx b/components/chat.tsx index 2d851d1..f48d816 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -70,6 +70,7 @@ import { CoverageRing } from "@/components/extraction-coverage"; import { Button } from "@/components/ui/button"; import { activeProblemOf, + danglingQuestionOf, dismissedAnswer, endsInFailedTurn, findPendingToolCall, @@ -444,7 +445,7 @@ export function Chat({ // mount-only by design (turnInFlight read at initial value) }, []); - const pending = findPendingToolCall(messages); + const pending = findPendingToolCall(messages, danglingQuestionOf(nativeMessages)?.toolCallId ?? null); // The abort handler reads the pending question from a ref (the window // listener is mount-scoped; the value it needs is render-scoped). const pendingRef = useRef(pending); diff --git a/src/core/pending.ts b/src/core/pending.ts index b1ffd1a..aa9134a 100644 --- a/src/core/pending.ts +++ b/src/core/pending.ts @@ -105,14 +105,14 @@ export function filterEnabledTools(tools: Record, enabled?: Enable return out; } -function findPending(messages: UIMessage[], names: readonly string[]): PendingToolCall | null { +function findPending(messages: UIMessage[], names: readonly string[], toolCallId?: string): PendingToolCall | null { for (let m = messages.length - 1; m >= 0; m--) { const msg = messages[m]!; for (let p = 0; p < msg.parts.length; p++) { const part = msg.parts[p]!; if (isToolUIPart(part) && part.state === "input-available") { const tool = part.type.slice("tool-".length); - if (names.includes(tool)) { + if (names.includes(tool) && (toolCallId === undefined || part.toolCallId === toolCallId)) { return { toolCallId: part.toolCallId as string, tool, input: part.input }; } } @@ -121,8 +121,12 @@ function findPending(messages: UIMessage[], names: readonly string[]): PendingTo return null; } -export function findPendingToolCall(messages: UIMessage[]): PendingToolCall | null { - return findPending(messages, QUESTION_TOOLS); +/** Grouped view messages lose native assistant boundaries. When the native + * record supplies an authority, expose only that call after view validation. + * null means no native question is pending; undefined keeps view-only callers. */ +export function findPendingToolCall(messages: UIMessage[], toolCallId?: string | null): PendingToolCall | null { + if (toolCallId === null) return null; + return findPending(messages, QUESTION_TOOLS, toolCallId); } // ─── Turn activity label (2026-09-19, the “Writing a question…” fix) ────── diff --git a/tests/pending-authority.test.ts b/tests/pending-authority.test.ts new file mode 100644 index 0000000..633f651 --- /dev/null +++ b/tests/pending-authority.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { findPendingToolCall } from "../src/core/pending.ts"; +import { danglingQuestionOf, newAnswerResult, type LhMessage } from "../src/core/native-messages.ts"; +import { inputAppends } from "../src/core/input-events.ts"; +import { projectView } from "../src/core/view.ts"; + +function assistant(id: string, ...calls: string[]): LhMessage { + return { id, role: "assistant", content: calls.map(id => ({ type: "toolCall", id, name: "quiz", arguments: { question: id } })), stopReason: "toolUse", timestamp: 1 } as LhMessage; +} + +function pending(messages: LhMessage[], view = projectView(messages)) { + return findPendingToolCall(view, danglingQuestionOf(messages)?.toolCallId ?? null); +} + +test("a later assistant question supersedes an unanswered question in the same projected group", () => { + const messages = [assistant("old", "old-question"), assistant("new", "new-question")]; + assert.equal(projectView(messages).length, 1, "the view merges both assistant boundaries"); + const question = pending(messages)!; + assert.equal(question.toolCallId, "new-question"); + assert.doesNotThrow(() => inputAppends({ messages }, { type: "answer", toolCallId: question.toolCallId, answer: { kind: "quiz", answers: [], dontKnow: true } as never })); +}); + +test("the newest assistant's parallel questions retain call order and never revive older questions", () => { + const messages = [assistant("old", "old-question"), assistant("new", "first", "second")]; + assert.equal(pending(messages)?.toolCallId, "first"); + messages.push({ ...newAnswerResult("first", "quiz", { kind: "quiz", answers: [], dontKnow: true } as never), id: "answer-first" }); + assert.equal(pending(messages)?.toolCallId, "second"); + messages.push({ ...newAnswerResult("second", "quiz", { kind: "quiz", answers: [], dontKnow: true } as never), id: "answer-second" }); + assert.equal(pending(messages), null); +}); + +test("the authoritative question still waits for projected input validation", () => { + const messages = [assistant("old", "old-question"), assistant("new", "new-question")]; + const view = projectView(messages); + for (const message of view) for (const part of message.parts) { + if (part.type === "tool-quiz" && part.toolCallId === "new-question") part.state = "input-streaming"; + } + assert.equal(pending(messages, view), null); +}); From 6ca431644d1f3f7027cde8bedb08c11364faf139 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 08:01:50 +0200 Subject: [PATCH 65/87] fix: enforce Claude CLI MCP admission and safe continuation --- src/agent/claude-code/binary.ts | 4 +- src/agent/claude-code/cli-events.ts | 13 +- src/agent/claude-code/live-processes.ts | 9 +- src/agent/claude-code/reuse-context.ts | 27 ++ src/agent/claude-code/stream.ts | 313 +++++++++++++-------- src/agent/claude-code/tool-bridge.ts | 40 ++- src/agent/teacher-agent.ts | 3 +- tests/claude-code-binary.test.ts | 6 +- tests/claude-code-harness.test.ts | 30 +- tests/claude-code-stream.test.ts | 219 +++++++++++--- tests/claude-code-tool-bridge.test.ts | 35 ++- tests/fixtures/claude-code/fake-claude.mjs | 33 ++- tests/send-to-user.test.ts | 3 + 13 files changed, 534 insertions(+), 201 deletions(-) create mode 100644 src/agent/claude-code/reuse-context.ts diff --git a/src/agent/claude-code/binary.ts b/src/agent/claude-code/binary.ts index 81b4a55..13e86e6 100644 --- a/src/agent/claude-code/binary.ts +++ b/src/agent/claude-code/binary.ts @@ -21,7 +21,9 @@ function getWindowsSearchSpec(env: BinaryLookup["env"], homeDir: string): Search return { paths, - names: ["claude.exe", "claude.cmd"], + // Direct spawn deliberately uses no shell. A .cmd-only npm install + // is not runnable and must not count as an available provider. + names: ["claude.exe"], knownDirs: [ paths.join(homeDir, ".local", "bin"), paths.join(homeDir, ".claude", "local"), diff --git a/src/agent/claude-code/cli-events.ts b/src/agent/claude-code/cli-events.ts index f532946..f3dddca 100644 --- a/src/agent/claude-code/cli-events.ts +++ b/src/agent/claude-code/cli-events.ts @@ -14,7 +14,8 @@ export type CliEvent = | { type: "messageDelta"; stopReason: string | null; usage: CliUsage } | { type: "messageStop" } | { type: "assistantError"; error: string; text: string } - | { type: "resultError"; text: string }; + | { type: "resultError"; text: string } + | { type: "toolResults"; ids: string[] }; type RawUsage = { input_tokens?: number | null; @@ -46,7 +47,7 @@ type RawCliLine = { type?: string; event?: RawStreamEvent; error?: string | null; - message?: { content?: { type: string; text?: string }[] }; + message?: { content?: { type: string; text?: string; tool_use_id?: string }[] }; is_error?: boolean; result?: string; subtype?: string; @@ -139,7 +140,7 @@ function parseStreamEvent(event: RawStreamEvent): CliEvent | null { } function getText(line: RawCliLine): string { - const blocks = line.message?.content ?? []; + const blocks = Array.isArray(line.message?.content) ? line.message.content : []; const texts = blocks.flatMap((block) => (block.type === "text" && block.text ? [block.text] : [])); return texts.join("\n"); @@ -171,6 +172,12 @@ export function parseCliLine(text: string): CliEvent | null { if (typeof line.error !== "string") return null; return { type: "assistantError", error: line.error, text: getText(line) }; + case "user": { + const blocks = Array.isArray(line.message?.content) ? line.message.content : []; + const ids = blocks.flatMap((block) => block.type === "tool_result" && typeof block.tool_use_id === "string" ? [block.tool_use_id] : []); + return ids.length ? { type: "toolResults", ids } : null; + } + case "result": if (line.is_error !== true) return null; return { type: "resultError", text: getResultErrorText(line) }; diff --git a/src/agent/claude-code/live-processes.ts b/src/agent/claude-code/live-processes.ts index 61f036a..1a68672 100644 --- a/src/agent/claude-code/live-processes.ts +++ b/src/agent/claude-code/live-processes.ts @@ -1,4 +1,4 @@ -export type LiveProcess = { toolCallIds: string[]; kill(): void }; +export type LiveProcess = { readonly owner: symbol; toolCallIds: string[]; kill(): void }; // Tools usually call back within seconds, but a question tool holds its call until the learner // answers. Past this, the process is killed and the answer starts a fresh one with a full replay. @@ -12,6 +12,7 @@ export const liveProcessIdleTimeoutMs = { current: LIVE_PROCESS_IDLE_TIMEOUT_MS const liveProcesses = new Map(); export function addLiveProcess(process: LiveProcess): void { + removeLiveProcess(process); const timer = setTimeout(() => { removeLiveProcess(process); process.kill(); @@ -23,14 +24,14 @@ export function addLiveProcess(process: LiveProcess): void { } function hasSameIds(ids: string[], otherIds: string[]): boolean { - return ids.length === otherIds.length && ids.every((id) => otherIds.includes(id)); + return ids.length === otherIds.length && new Set(otherIds).size === otherIds.length && ids.every((id) => otherIds.includes(id)); } -export function getLiveProcess(toolCallIds: string[]): LiveProcess | null { +export function getLiveProcess(toolCallIds: string[], owner: symbol): LiveProcess | null { if (toolCallIds.length === 0) return null; for (const process of liveProcesses.keys()) { - if (hasSameIds(process.toolCallIds, toolCallIds)) return process; + if (process.owner === owner && hasSameIds(process.toolCallIds, toolCallIds)) return process; } return null; diff --git a/src/agent/claude-code/reuse-context.ts b/src/agent/claude-code/reuse-context.ts new file mode 100644 index 0000000..63cbd7d --- /dev/null +++ b/src/agent/claude-code/reuse-context.ts @@ -0,0 +1,27 @@ +import type { Api, Message, Model, TranscriptContext } from "@earendil-works/pi-ai"; +import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript"; + +/** Compare model-visible history, not app IDs, timestamps, usage, or Pi's added thinkingLevel. */ +export function conversationForReuse(messages: Message[]): unknown[] { + return messages.flatMap((message): unknown[] => { + switch (message.role) { + case "system": return []; + case "user": return [{ role: message.role, content: message.content }]; + case "assistant": return [{ role: message.role, content: message.content }]; + case "toolResult": return [{ + role: message.role, + toolCallId: message.toolCallId, + toolName: message.toolName, + content: message.content, + isError: message.isError, + }]; + } + }); +} + +export function configurationForReuse(model: Model, context: TranscriptContext, effort: string): unknown { + const systemPrompt = getCurrentSystemPrompt(context.messages); + const tools = getCurrentTools(context.messages); + // The wire is JSON: TypeBox's schema symbols are not CLI configuration. + return JSON.parse(JSON.stringify({ model, effort, systemPrompt, tools })) as unknown; +} diff --git a/src/agent/claude-code/stream.ts b/src/agent/claude-code/stream.ts index 0f9dd4d..cd27167 100644 --- a/src/agent/claude-code/stream.ts +++ b/src/agent/claude-code/stream.ts @@ -16,11 +16,9 @@ import { type StopReason, type StreamFunction, type ThinkingLevel, - type Tool, type ToolCall, type ToolResultMessage, type TranscriptContext, - validateToolCall, } from "@earendil-works/pi-ai"; import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messages"; import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse"; @@ -29,7 +27,8 @@ import { getClaudeBinary } from "./binary.ts"; import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts"; import { ClaudeCodeError } from "./constants.ts"; import { addLiveProcess, getLiveProcess, removeLiveProcess, type LiveProcess } from "./live-processes.ts"; -import { createToolBridge, toolNameFromMcp, type PendingToolCall, type ToolBridge } from "./tool-bridge.ts"; +import { configurationForReuse, conversationForReuse } from "./reuse-context.ts"; +import { BRIDGE_SERVER_NAME, createToolBridge, toolNameFromMcp, type PendingToolCall, type ToolBridge } from "./tool-bridge.ts"; import { toClaudeInput, toStreamJsonLine } from "./transcript.ts"; type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv }; @@ -137,6 +136,8 @@ class ClaudeTurn { private readonly output: AssistantMessage; private readonly contentIndexes = new Map(); private readonly partialJson = new Map(); + private readonly toolBlocks = new Map(); + private readonly intents: ToolCall[] = []; started = false; finished = false; @@ -188,13 +189,12 @@ class ClaudeTurn { const found = this.getBlock(event.index); if (!found) return; - const { contentIndex, block } = found; + const { block } = found; if (block.type !== "toolCall") return; const partialJson = (this.partialJson.get(event.index) ?? "") + event.partialJson; this.partialJson.set(event.index, partialJson); block.arguments = parseStreamingJson(partialJson); - this.stream.push({ type: "toolcall_delta", contentIndex, delta: event.partialJson, partial: this.output }); return; } @@ -208,7 +208,9 @@ class ClaudeTurn { return; case "messageStop": - this.finish(); + // Tool intent is not execution admission. The process publishes only + // calls that actually reach the authenticated MCP bridge. + if (this.output.stopReason !== "toolUse") this.finish(); return; case "assistantError": @@ -221,10 +223,22 @@ class ClaudeTurn { } } - getToolCalls(): ToolCall[] { - if (!this.finished || this.output.stopReason !== "toolUse") return []; + get waitingForAdmission(): boolean { return !this.finished && this.output.stopReason === "toolUse"; } - return this.output.content.filter((block) => block.type === "toolCall"); + getIntents(): ToolCall[] { return this.intents; } + + getMessage(): AssistantMessage { return this.output; } + + admit(calls: ToolCall[]): void { + this.start(); + for (const call of calls) { + const contentIndex = this.output.content.length; + this.output.content.push(call); + this.stream.push({ type: "toolcall_start", contentIndex, partial: this.output }); + this.stream.push({ type: "toolcall_end", contentIndex, toolCall: call, partial: this.output }); + } + this.output.stopReason = "toolUse"; + this.finish(); } fail(message: string, reason: "aborted" | "error"): void { @@ -260,13 +274,18 @@ class ClaudeTurn { return; case "toolUse": - this.output.content.push({ type: "toolCall", id: block.id, name: toolNameFromMcp(block.name), arguments: {} }); - this.stream.push({ type: "toolcall_start", contentIndex, partial: this.output }); + if (!block.name.startsWith(`mcp__${BRIDGE_SERVER_NAME}__`)) { + this.fail("claude CLI emitted an unsupported native tool call", "error"); + return; + } + this.toolBlocks.set(index, { type: "toolCall", id: block.id, name: toolNameFromMcp(block.name), arguments: {} }); return; } } private getBlock(index: number): { contentIndex: number; block: AssistantMessage["content"][number] } | null { + const tool = this.toolBlocks.get(index); + if (tool) return { contentIndex: -1, block: tool }; const contentIndex = this.contentIndexes.get(index); if (contentIndex === undefined) return null; @@ -293,7 +312,8 @@ class ClaudeTurn { case "toolCall": Object.assign(block, parseToolCallArguments(this.partialJson.get(index) ?? "")); this.partialJson.delete(index); - this.stream.push({ type: "toolcall_end", contentIndex, toolCall: block satisfies ToolCall, partial: this.output }); + this.toolBlocks.delete(index); + this.intents.push(block); return; } } @@ -340,51 +360,48 @@ type TurnFiles = { cwd: string; privateDir: string; systemPromptFile: string; mc async function createTurnFiles(systemPrompt: string, mcpConfig: string): Promise { const cwd = await mkdtemp(join(tmpdir(), "claude-code-cwd-")); - const privateDir = await mkdtemp(join(tmpdir(), "claude-code-private-")); - - const systemPromptFile = join(privateDir, "system-prompt.md"); - const mcpConfigFile = join(privateDir, "mcp.json"); - - await writeFile(systemPromptFile, systemPrompt, { mode: PRIVATE_FILE_MODE }); - await writeFile(mcpConfigFile, mcpConfig, { mode: PRIVATE_FILE_MODE }); - - return { cwd, privateDir, systemPromptFile, mcpConfigFile }; -} - -async function removeTurnResources(bridge: ToolBridge, files: TurnFiles): Promise { - await bridge.close(); - await rm(files.cwd, { recursive: true, force: true }); - await rm(files.privateDir, { recursive: true, force: true }); -} - -type UnansweredToolCall = { toolCall: ToolCall; result: ToolResultMessage }; - -// The CLI answers calls to undeclared tools or with invalid arguments itself, without reaching the bridge. -function isValidToolCall(tools: Tool[], toolCall: ToolCall): boolean { + let privateDir: string | undefined; try { - validateToolCall(tools, toolCall); - return true; - } catch { - return false; + privateDir = await mkdtemp(join(tmpdir(), "claude-code-private-")); + const systemPromptFile = join(privateDir, "system-prompt.md"); + const mcpConfigFile = join(privateDir, "mcp.json"); + await writeFile(systemPromptFile, systemPrompt, { mode: PRIVATE_FILE_MODE }); + await writeFile(mcpConfigFile, mcpConfig, { mode: PRIVATE_FILE_MODE }); + return { cwd, privateDir, systemPromptFile, mcpConfigFile }; + } catch (error) { + await Promise.all([rm(cwd, { recursive: true, force: true }), ...(privateDir ? [rm(privateDir, { recursive: true, force: true })] : [])]); + throw error; } } -function isSameToolCall(toolCall: ToolCall, call: PendingToolCall): boolean { - return toolCall.name === call.name && isDeepStrictEqual(toolCall.arguments, call.arguments); +async function removeTurnResources(bridge: ToolBridge, files: TurnFiles): Promise { + await Promise.all([ + bridge.close(), + rm(files.cwd, { recursive: true, force: true }), + rm(files.privateDir, { recursive: true, force: true }), + ]); } class ClaudeProcess implements LiveProcess { toolCallIds: string[] = []; private stderr = ""; private closed = false; + private stopping = false; + private killTimer: NodeJS.Timeout | undefined; private signal: AbortSignal | undefined; + private sourceCalls: ToolCall[] = []; + private sourceComplete = false; + private admitted: PendingToolCall[] = []; + private pendingError: string | undefined; constructor( private readonly child: ChildProcessWithoutNullStreams, private readonly bridge: ToolBridge, private readonly files: TurnFiles, - private readonly tools: Tool[], private turn: ClaudeTurn, + readonly owner: symbol, + private readonly configuration: unknown, + private conversation: unknown[], ) {} start(stdinLine: string, signal: AbortSignal | undefined): void { @@ -394,39 +411,92 @@ class ClaudeProcess implements LiveProcess { this.turn.fail(START_FAILED_MESSAGE, "error"); this.handleClose(START_FAILED_MESSAGE); }); - this.child.stderr.setEncoding("utf8"); - this.child.stderr.on("data", (chunk: string) => (this.stderr += chunk)); - + this.child.stderr.on("data", (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-65_536); }); this.child.stdin.on("error", (error) => console.error("claude CLI stdin closed early:", error)); this.child.stdin.end(`${stdinLine}\n`); - - createInterface({ input: this.child.stdout }).on("line", (line) => this.handleLine(line)); - + createInterface({ input: this.child.stdout }).on("line", (line) => { + try { this.handleLine(line); } + catch { this.fail("claude CLI emitted malformed stream output"); } + }); this.setSignal(signal); } - continueTurn(turn: ClaudeTurn, toolResults: ToolResultMessage[], signal: AbortSignal | undefined): void { + canContinue(owner: symbol, configuration: unknown, messages: Message[], results: ToolResultMessage[]): boolean { + const conversation = conversationForReuse(messages); + return !this.stopping && owner === this.owner + && isDeepStrictEqual(configuration, this.configuration) + && isDeepStrictEqual(conversation.slice(0, this.conversation.length), this.conversation) + && conversation.length === this.conversation.length + this.admitted.length + && results.length === this.admitted.length + && new Set(results.map((result) => result.toolCallId)).size === results.length + && this.admitted.every((call) => results.some((result) => result.toolCallId === call.id && result.toolName === call.name)); + } + + continueTurn(turn: ClaudeTurn, messages: Message[], results: ToolResultMessage[], signal: AbortSignal | undefined): void { removeLiveProcess(this); - - const unanswered = this.turn.getToolCalls().flatMap((toolCall) => { - const result = toolResults.find((toolResult) => toolResult.toolCallId === toolCall.id); - return result ? [{ toolCall, result }] : []; - }); - + const admitted = this.admitted; + this.admitted = []; + this.conversation = structuredClone(conversationForReuse(messages)); this.turn = turn; + this.sourceComplete = this.sourceCalls.length > 0; this.setSignal(signal); - - this.answerToolCalls(turn, unanswered).catch((error: unknown) => { - console.error("claude CLI tool results failed:", error); - turn.fail(START_FAILED_MESSAGE, "error"); - this.kill(); - }); + if (this.stopping) return; + for (const call of admitted) { + const result = results.find((candidate) => candidate.toolCallId === call.id)!; + this.bridge.resolveToolCall(call, result); + } + this.flushAdmissions(); } kill(): void { removeLiveProcess(this); + if (this.stopping || this.closed) return; + this.stopping = true; this.child.kill(); + this.killTimer = setTimeout(() => this.child.kill("SIGKILL"), 1000); + this.killTimer.unref(); + } + + fail(message: string): void { + // A parallel request can fail after the admitted slice was handed to + // Pi. Retain that failure for its continuation instead of silently + // replaying and hiding the compatibility error. + if (this.turn.finished && this.toolCallIds.length > 0) this.pendingError = message; + this.turn.fail(message, "error"); + this.kill(); + if (this.pendingError) addLiveProcess(this); + } + + consumeFailure(owner: symbol, turn: ClaudeTurn): boolean { + if (owner !== this.owner || !this.pendingError) return false; + const message = this.pendingError; + this.pendingError = undefined; + this.kill(); + turn.fail(message, "error"); + return true; + } + + /** Only authenticated MCP requests, never stdout intent alone, admit side effects. */ + flushAdmissions(): void { + if (this.stopping || this.turn.finished || !this.sourceComplete) return; + const pending = this.bridge.getPendingToolCalls(); + if (pending.length === 0) return; + const calls: ToolCall[] = []; + for (const call of pending) { + const intent = this.sourceCalls.find((candidate) => candidate.id === call.id); + if (!intent || intent.name !== call.name || !isDeepStrictEqual(intent.arguments, call.arguments)) { + this.fail("claude CLI compatibility error: MCP call does not match its streamed tool intent"); + return; + } + calls.push(intent); + } + this.sourceCalls = this.sourceCalls.filter((call) => !pending.some((admitted) => admitted.id === call.id)); + this.admitted = pending; + this.toolCallIds = pending.map((call) => call.id); + this.turn.admit(calls); + this.conversation.push(...structuredClone(conversationForReuse([this.turn.getMessage()]))); + addLiveProcess(this); } private readonly abort = () => { @@ -438,96 +508,72 @@ class ClaudeProcess implements LiveProcess { this.signal?.removeEventListener("abort", this.abort); this.signal = signal; signal?.addEventListener("abort", this.abort, { once: true }); - if (signal?.aborted) this.abort(); } private handleLine(line: string): void { + if (this.stopping) return; const event = parseCliLine(line); if (!event) return; - - // Output after a finished message means the CLI moved on without waiting for our tool results. - if (this.turn.finished) { - this.kill(); - return; - } - - this.turn.handle(event); - if (!this.turn.finished) return; - - const toolCalls = this.turn.getToolCalls(); - this.toolCallIds = toolCalls.map((toolCall) => toolCall.id); - - const reachesBridge = toolCalls.length > 0 && toolCalls.every((toolCall) => isValidToolCall(this.tools, toolCall)); - if (!reachesBridge) { - this.kill(); - return; - } - - addLiveProcess(this); - } - - private async answerToolCalls(turn: ClaudeTurn, unanswered: UnansweredToolCall[]): Promise { - while (unanswered.length > 0 && !turn.started) { - const pending = this.bridge.getPendingToolCalls(); - let answered = false; - - for (const call of pending) { - const index = unanswered.findIndex(({ toolCall }) => isSameToolCall(toolCall, call)); - const match = unanswered[index]; - if (!match) continue; - - this.bridge.resolveToolCall(call, match.result); - unanswered.splice(index, 1); - answered = true; + if (event.type === "toolResults") { + if (event.ids.some((id) => this.sourceCalls.some((call) => call.id === id))) { + this.fail("claude CLI refused a harness tool call before MCP admission"); } - - if (answered) continue; - - // Rejects only once the bridge has closed, which means the process is already gone. - const next = await this.bridge.waitForToolCalls(pending.length + 1).catch(() => null); - if (!next) return; + return; + } + // Moving on before held calls were answered means the CLI did not + // honor this tool wait. Never execute further stdout-only intentions. + if (this.turn.finished) { + this.fail("claude CLI moved on while harness tool calls were waiting for results"); + return; + } + if (event.type === "messageStart" && this.sourceComplete && this.sourceCalls.length > 0) { + this.fail("claude CLI moved on without admitting its harness tool calls"); + return; + } + this.turn.handle(event); + if (this.turn.finished) { this.kill(); return; } + if (event.type === "messageStop" && this.turn.waitingForAdmission) { + this.sourceCalls = this.turn.getIntents(); + if (this.sourceCalls.length === 0 || new Set(this.sourceCalls.map((call) => call.id)).size !== this.sourceCalls.length) { + this.fail("claude CLI emitted an invalid tool batch"); + return; + } + this.sourceComplete = true; + this.flushAdmissions(); } } private handleClose(message: string): void { if (this.closed) return; - this.closed = true; + clearTimeout(this.killTimer); removeLiveProcess(this); + if (this.pendingError) addLiveProcess(this); this.signal?.removeEventListener("abort", this.abort); - if (this.stderr.trim()) console.error("claude CLI stderr:", this.stderr.trim()); - this.turn.fail(message, "error"); - removeTurnResources(this.bridge, this.files).catch((error: unknown) => { console.error("claude CLI cleanup failed:", error); }); } } -function continueLiveProcess(turn: ClaudeTurn, messages: Message[], signal: AbortSignal | undefined): boolean { +function continueLiveProcess(turn: ClaudeTurn, owner: symbol, configuration: unknown, messages: Message[], signal: AbortSignal | undefined): boolean { const assistantIndex = messages.findLastIndex((message) => message.role === "assistant"); const assistant = messages[assistantIndex]; if (assistant?.role !== "assistant") return false; - - const toolCallIds = assistant.content.flatMap((block) => (block.type === "toolCall" ? [block.id] : [])); - const process = getLiveProcess(toolCallIds); + const ids = assistant.content.flatMap((block) => block.type === "toolCall" ? [block.id] : []); + const process = getLiveProcess(ids, owner); if (!(process instanceof ClaudeProcess)) return false; - + if (process.consumeFailure(owner, turn)) return true; const trailing = messages.slice(assistantIndex + 1); - const toolResults = trailing.filter((message) => message.role === "toolResult"); - - const onlyToolResults = toolResults.length === trailing.length; - const answersEveryCall = toolCallIds.every((id) => toolResults.some((toolResult) => toolResult.toolCallId === id)); - - if (!onlyToolResults || !answersEveryCall) { + const results = trailing.filter((message) => message.role === "toolResult"); + if (trailing.length !== results.length || !process.canContinue(owner, configuration, messages, results)) { process.kill(); return false; } - - process.continueTurn(turn, toolResults, signal); + process.continueTurn(turn, messages, results, signal); return true; } @@ -549,10 +595,28 @@ async function startProcess( model: Model, context: TranscriptContext, options: SimpleStreamOptions | undefined, + owner: symbol, + configuration: unknown, ): Promise { + if (options?.signal?.aborted) { turn.fail(ABORT_MESSAGE, "aborted"); return; } const input = toClaudeInput(context.messages); - const bridge = await createToolBridge(input.tools); - const files = await createTurnFiles(input.systemPrompt, bridge.mcpConfig); + let cliProcess: ClaudeProcess | undefined; + const bridge = await createToolBridge(input.tools, { + onCall: () => cliProcess?.flushAdmissions(), + onError: (error) => cliProcess?.fail(error.message), + }); + let files: TurnFiles; + try { + files = await createTurnFiles(input.systemPrompt, bridge.mcpConfig); + } catch (error) { + await bridge.close(); + throw error; + } + if (options?.signal?.aborted) { + turn.fail(ABORT_MESSAGE, "aborted"); + await removeTurnResources(bridge, files); + return; + } let child: ChildProcessWithoutNullStreams | null; try { @@ -575,17 +639,20 @@ async function startProcess( return; } - new ClaudeProcess(child, bridge, files, input.tools, turn).start(toStreamJsonLine(input.userContent), options?.signal); + cliProcess = new ClaudeProcess(child, bridge, files, turn, owner, configuration, structuredClone(conversationForReuse(context.messages))); + cliProcess.start(toStreamJsonLine(input.userContent), options?.signal); } export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunction { + const owner = Symbol("claude-code stream owner"); return (model, context, options) => { const stream = createAssistantMessageEventStream(); const turn = new ClaudeTurn(stream, model); - if (continueLiveProcess(turn, context.messages, options?.signal)) return stream; + const configuration = configurationForReuse(model, context, toEffort(options?.reasoning)); + if (continueLiveProcess(turn, owner, configuration, context.messages, options?.signal)) return stream; - startProcess(runClaude, turn, model, context, options).catch((error: unknown) => { + startProcess(runClaude, turn, model, context, options, owner, configuration).catch((error: unknown) => { console.error("claude CLI turn failed:", error); turn.fail(START_FAILED_MESSAGE, "error"); }); diff --git a/src/agent/claude-code/tool-bridge.ts b/src/agent/claude-code/tool-bridge.ts index 92e2aad..db9f4da 100644 --- a/src/agent/claude-code/tool-bridge.ts +++ b/src/agent/claude-code/tool-bridge.ts @@ -7,7 +7,9 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { CallToolRequestSchema, + ErrorCode, ListToolsRequestSchema, + McpError, type CallToolResult, type Tool as McpTool, } from "@modelcontextprotocol/sdk/types.js"; @@ -18,7 +20,11 @@ const MCP_TOOL_PREFIX = `mcp__${BRIDGE_SERVER_NAME}__`; const LOOPBACK_HOST = "127.0.0.1"; const TOKEN_BYTES = 32; -export type PendingToolCall = { name: string; arguments: JsonObject }; +export type PendingToolCall = { id: string; name: string; arguments: JsonObject }; + +type BridgeCallbacks = { onCall(): void; onError(error: Error): void }; + +export const MCP_IDENTITY_ERROR = "claude CLI compatibility error: MCP calls must include a unique claudecode/toolUseId"; export type ToolBridge = { mcpConfig: string; @@ -104,14 +110,28 @@ class ToolCallQueue { // Low-level Server on purpose: McpServer.registerTool only takes zod schemas, and pi tools carry // JSON Schema that must reach the CLI unchanged. -function createMcpServer(tools: Tool[], queue: ToolCallQueue): Server { +function createMcpServer(tools: Tool[], queue: ToolCallQueue, callbacks?: BridgeCallbacks): Server { const server = new Server({ name: BRIDGE_SERVER_NAME, version: "1.0.0" }, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: tools.map(toMcpTool) })); + const seenIds = new Set(); server.setRequestHandler(CallToolRequestSchema, (request) => { - const call = { name: request.params.name, arguments: (request.params.arguments ?? {}) as JsonObject }; - return queue.add(call); + const id = request.params._meta?.["claudecode/toolUseId"]; + if (typeof id !== "string" || !id.trim() || seenIds.has(id)) { + const error = new McpError(ErrorCode.InvalidParams, MCP_IDENTITY_ERROR); + callbacks?.onError(error); + throw error; + } + if (!tools.some((tool) => tool.name === request.params.name)) { + const error = new McpError(ErrorCode.InvalidParams, "claude CLI requested an undeclared harness tool"); + callbacks?.onError(error); + throw error; + } + seenIds.add(id); + const result = queue.add({ id, name: request.params.name, arguments: (request.params.arguments ?? {}) as JsonObject }); + callbacks?.onCall(); + return result; }); return server; @@ -167,16 +187,22 @@ function toMcpConfig(port: number, authorization: string): string { return JSON.stringify({ mcpServers: { [BRIDGE_SERVER_NAME]: server } }); } -export async function createToolBridge(tools: Tool[]): Promise { +export async function createToolBridge(tools: Tool[], callbacks?: BridgeCallbacks): Promise { const authorization = `Bearer ${randomBytes(TOKEN_BYTES).toString("hex")}`; const queue = new ToolCallQueue(); - const mcpServer = createMcpServer(tools, queue); + const mcpServer = createMcpServer(tools, queue, callbacks); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); await mcpServer.connect(transport); const httpServer = createHttpServer(transport, authorization); - const { port } = await listen(httpServer); + let port: number; + try { + ({ port } = await listen(httpServer)); + } catch (error) { + await mcpServer.close(); + throw error; + } return { mcpConfig: toMcpConfig(port, authorization), diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts index 6fe6ac1..cfdab68 100644 --- a/src/agent/teacher-agent.ts +++ b/src/agent/teacher-agent.ts @@ -17,6 +17,7 @@ import { import { practiceTools, type PracticeToolContext } from "./tools/practice.ts"; import { healToolRecord, withArgumentHeal } from "./tools/heal.ts"; import { sendToUserTool } from "./tools/send-to-user.ts"; +import { CLAUDE_CODE_PROVIDER } from "./claude-code/constants.ts"; import { quizInputObject, quizInputProblems } from "../core/question-core.ts"; import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts"; import { toLlmMessages, nativeInputCause, PRACTICE_PROBLEM_TOOL } from "../core/native-messages.ts"; @@ -202,7 +203,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent // transport. Explicit test registries remain caller-owned. tools: [ ...tools, - ...(opts.tools === undefined && engine.model.id.toLowerCase().includes("claude") + ...(opts.tools === undefined && (engine.model.provider === CLAUDE_CODE_PROVIDER || engine.model.id.toLowerCase().includes("claude")) ? [withArgumentHeal(sendToUserTool)] : []), ], diff --git a/tests/claude-code-binary.test.ts b/tests/claude-code-binary.test.ts index 66a716a..599f5d9 100644 --- a/tests/claude-code-binary.test.ts +++ b/tests/claude-code-binary.test.ts @@ -39,15 +39,15 @@ test("npm global bin follows npm_config_prefix when set", () => { assert.equal(binary, "/opt/npm/bin/claude"); }); -test("windows looks for claude.cmd and claude.exe", () => { +test("windows finds native claude.exe and does not advertise unsupported .cmd-only installs", () => { const winHome = "C:\\Users\\u"; const env = { PATH: "C:\\tools;C:\\bin", APPDATA: `${winHome}\\AppData\\Roaming` }; - assert.equal(resolveWith(["C:\\bin\\claude.cmd"], env, "win32", winHome), "C:\\bin\\claude.cmd"); + assert.equal(resolveWith(["C:\\bin\\claude.cmd"], env, "win32", winHome), null); assert.equal(resolveWith(["C:\\bin\\claude.exe"], env, "win32", winHome), "C:\\bin\\claude.exe"); assert.equal(resolveWith([`${winHome}\\.local\\bin\\claude.exe`], env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`); assert.equal( resolveWith([`${winHome}\\AppData\\Roaming\\npm\\claude.cmd`], env, "win32", winHome), - `${winHome}\\AppData\\Roaming\\npm\\claude.cmd`, + null, ); assert.equal(resolveWith(["C:\\bin\\claude"], env, "win32", winHome), null); }); diff --git a/tests/claude-code-harness.test.ts b/tests/claude-code-harness.test.ts index 1dcd8c2..d300b35 100644 --- a/tests/claude-code-harness.test.ts +++ b/tests/claude-code-harness.test.ts @@ -55,7 +55,7 @@ function toAskScenario(): string[] { streamEvent({ type: "content_block_stop", index: 0 }), streamEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }), streamEvent({ type: "message_stop" }), - JSON.stringify({ fake: "call", name: "ask_user_question", arguments: ASK_INPUT }), + JSON.stringify({ fake: "call", id: ASK_ID, name: "ask_user_question", arguments: ASK_INPUT }), JSON.stringify({ fake: "reply" }), ]; } @@ -168,6 +168,34 @@ test("a question answered before the idle timeout continues the waiting CLI proc await waitForExit(waiting); }); +test("a question pauses later calls from the same CLI response until the learner answers", async (t) => { + const secondId = "toolu_second_question"; + const secondQuestion = "What do you want to learn next?"; + const scenario = toAskScenario(); + scenario.splice(4, 0, + streamEvent({ type: "content_block_start", index: 1, content_block: { type: "tool_use", id: secondId, name: "mcp__learnharness__ask_user_question", input: {} } }), + streamEvent({ type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: JSON.stringify({ question: secondQuestion }) } }), + streamEvent({ type: "content_block_stop", index: 1 }), + ); + scenario.splice(scenario.length - 1, 0, JSON.stringify({ fake: "call", id: secondId, name: "ask_user_question", arguments: { question: secondQuestion } })); + const fakeRun = createFakeRun(t, [scenario]); + const factory = createAgentFactory(fakeRun); + const chatId = createClaudeCodeChat(t); + await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, factory); + assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, ASK_ID); + assert.equal(JSON.stringify(chatStore.getChat(chatId)?.messages).includes(secondId), false, "unadmitted second question must not be posed or persisted"); + await runChatTurn(chatId, toAnswerInput(), factory); + assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, secondId); + await runChatTurn(chatId, { + type: "answer", toolCallId: secondId, + answer: { kind: "ask", questionText: secondQuestion, dontKnow: false, answers: [], text: "HTTP next", answeredAt: new Date().toISOString() }, + }, factory); + assert.equal(fakeRun.children.length, 1); + assert.ok(getLastAssistantText(chatId).includes(ANSWER_TEXT)); + assert.ok(getLastAssistantText(chatId).includes("HTTP next")); + await waitForExit(fakeRun.children[0]!); +}); + test("a question answered after the idle timeout starts a fresh CLI process that replays the question and answer", async (t) => { liveProcessIdleTimeoutMs.current = 300; t.after(() => (liveProcessIdleTimeoutMs.current = LIVE_PROCESS_IDLE_TIMEOUT_MS)); diff --git a/tests/claude-code-stream.test.ts b/tests/claude-code-stream.test.ts index d5e48b3..efdd322 100644 --- a/tests/claude-code-stream.test.ts +++ b/tests/claude-code-stream.test.ts @@ -29,6 +29,8 @@ import type { ToolResultMessage, } from "@earendil-works/pi-ai"; import { normalizeContext } from "@earendil-works/pi-ai/utils/transcript"; +import { Agent } from "@earendil-works/pi-agent-core"; +import { Type } from "typebox"; import { parseCliLine } from "../src/agent/claude-code/cli-events.ts"; import { ClaudeCodeError } from "../src/agent/claude-code/constants.ts"; @@ -83,7 +85,8 @@ function exitWith(code: number, stderr: string): string { function getToolScenario(): string[] { const lines = readFixtureLines("tool.jsonl"); const firstMessageStop = lines.findIndex((line) => line.includes('"type":"message_stop"')); - return [...lines.slice(0, firstMessageStop + 1).filter((line) => !line.startsWith('{"type":"user"')), HOLD]; + return [...lines.slice(0, firstMessageStop + 1).filter((line) => !line.startsWith('{"type":"user"')), + callTool({ id: "toolu_01JBB4cJsPa585FWb1XrfbNX", name: "get_weather", arguments: { city: "Paris" } }), HOLD]; } function createFakeRun( @@ -104,9 +107,9 @@ function createFakeRun( const child = spawn(process.execPath, [FAKE_CLAUDE, ...args], { cwd: options.cwd, env: { ...options.env, FAKE_CLAUDE_SCENARIO: scenarioFile, ...extraEnv }, - stdio: "pipe", - }); - t.after(() => child.kill()); + stdio: ["pipe", "pipe", "pipe", "ipc"], + }) as ChildProcessWithoutNullStreams; + t.after(() => child.kill("SIGKILL")); calls.push({ args, cwd: options.cwd, @@ -151,7 +154,7 @@ async function runScenario( const context = normalizeContext({ systemPrompt: "You are a tutor.", messages: [{ role: "user", content: "hi", timestamp: 0 }], - tools: [READ_TOOL], + tools: [READ_TOOL, { name: "get_weather", description: "Weather", parameters: { type: "object", properties: {} } }], }); const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(model, context, options)); @@ -180,7 +183,7 @@ function toToolUseMessage(toolUses: ToolUse[]): string[] { } function callTool(toolUse: ToolUse): string { - return JSON.stringify({ fake: "call", name: toolUse.name, arguments: toolUse.arguments }); + return JSON.stringify({ fake: "call", id: toolUse.id, name: toolUse.name, arguments: toolUse.arguments }); } function toToolResult(toolUse: ToolUse, text: string): ToolResultMessage { @@ -368,7 +371,7 @@ test("tool scenario ends with toolcall_end (Claude's tool_use id, unprefixed nam assert.deepEqual( events.map((event) => event.type), - ["start", "toolcall_start", "toolcall_delta", "toolcall_delta", "toolcall_delta", "toolcall_end", "done"], + ["start", "toolcall_start", "toolcall_end", "done"], ); const toolCallEnd = events.find((event) => event.type === "toolcall_end"); @@ -386,7 +389,7 @@ test("tool scenario ends with toolcall_end (Claude's tool_use id, unprefixed nam assert.deepEqual(done.message.content, [toolCallEnd.toolCall]); }); -test("unknown tool name and bad JSON args still surface as tool calls", async (t) => { +test("unsupported native tool intent fails without exposing executable calls", async (t) => { const { events } = await runScenario(t, [ streamEvent({ type: "message_start", message: { usage: {} } }), streamEvent({ @@ -404,21 +407,8 @@ test("unknown tool name and bad JSON args still surface as tool calls", async (t HOLD, ]); - const toolCalls = events.flatMap((event) => (event.type === "toolcall_end" ? [event.toolCall] : [])); - assert.deepEqual( - toolCalls.map((call) => ({ id: call.id, name: call.name, arguments: call.arguments })), - [ - { id: "toolu_a", name: "nope", arguments: {} }, - { id: "toolu_b", name: "Bash", arguments: { command: "ls" } }, - ], - ); - - assert.match(toolCalls[0]?.argumentsError ?? "", /\{"path": /); - assert.equal(toolCalls[1]?.argumentsError, undefined); - - const done = getLastEvent(events); - assert.ok(done.type === "done"); - assert.equal(done.reason, "toolUse"); + assert.equal(events.some((event) => event.type === "toolcall_end"), false); + assert.match(getErrorMessage(events) ?? "", /unsupported native tool/); }); test("usage maps input/output/cacheRead/cacheWrite with zero cost, message_delta overriding message_start", async (t) => { @@ -585,7 +575,7 @@ test("spawns with the model alias, effort, bridge tools, MCP_TOOL_TIMEOUT and th const call = getOnlyCall(calls); assert.equal(getFlagValue(call.args, "--model"), "opus"); assert.equal(getFlagValue(call.args, "--effort"), "xhigh"); - assert.equal(getFlagValue(call.args, "--allowedTools"), "mcp__learnharness__read"); + assert.equal(getFlagValue(call.args, "--allowedTools"), "mcp__learnharness__read,mcp__learnharness__get_weather"); assert.equal(call.env.MCP_TOOL_TIMEOUT, "1800000"); assert.equal(call.env.HOME, process.env.HOME); assert.equal(call.env.CLAUDE_CONFIG_DIR, process.env.CLAUDE_CONFIG_DIR); @@ -641,27 +631,35 @@ test("trailing toolResults matching a live process continue it: held calls answe await assertCleanedUp(getOnlyCall(calls)); }); -test("multiple tool calls in one message are all answered by id", async (t) => { +test("sequential identical calls split one response and correlate results by id, not arguments", async (t) => { const readA: ToolUse = { id: "toolu_many_a", name: "read", arguments: { path: "a" } }; - const readB: ToolUse = { id: "toolu_many_b", name: "read", arguments: { path: "b" } }; - const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA, readB]), callTool(readB), callTool(readA), REPLY]); + const readB: ToolUse = { id: "toolu_many_b", name: "read", arguments: { path: "a" } }; + const source = toToolUseMessage([readA, readB]); + source[0] = streamEvent({ type: "message_start", message: { usage: { input_tokens: 7, output_tokens: 2 } } }); + const { runClaude, calls } = createFakeRun(t, [...source, callTool(readB), callTool(readA), REPLY]); const streamFunction = createClaudeCodeStreamFunction(runClaude); const toolMessage = await runTurn(streamFunction, [HI]); - const reply = await runTurn(streamFunction, [HI, toolMessage, toToolResult(readA, "A"), toToolResult(readB, "B")]); + assert.deepEqual(toolMessage.content.filter((block) => block.type === "toolCall").map((call) => call.id), [readB.id]); + const history = [HI, toolMessage, toToolResult(readB, "B")]; + const second = await runTurn(streamFunction, history); + assert.deepEqual(second.content.filter((block) => block.type === "toolCall").map((call) => call.id), [readA.id]); + const reply = await runTurn(streamFunction, [...history, second, toToolResult(readA, "A")]); assert.equal(getText(reply), "B | A"); + assert.equal(toolMessage.usage.totalTokens, 9); + assert.equal(second.usage.totalTokens, 0, "splitting one response must not charge its tokens twice"); assert.equal(calls.length, 1); }); -test("partial id match starts fresh and kills the old process", async (t) => { +test("mismatched result id starts fresh and kills the old process", async (t) => { const readA: ToolUse = { id: "toolu_partial_a", name: "read", arguments: { path: "a" } }; const readB: ToolUse = { id: "toolu_partial_b", name: "read", arguments: { path: "b" } }; const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA, readB]), callTool(readA), callTool(readB), REPLY]); const streamFunction = createClaudeCodeStreamFunction(runClaude); const toolMessage = await runTurn(streamFunction, [HI]); - await runTurn(streamFunction, [HI, toolMessage, toToolResult(readA, "A")]); + await runTurn(streamFunction, [HI, toolMessage, toToolResult(readB, "B")]); assert.equal(calls.length, 2); const [oldCall] = calls; @@ -695,24 +693,23 @@ test("user message after tool results starts fresh with unfinished calls in the test("output while waiting for tool results kills the process", async (t) => { const readA: ToolUse = { id: "toolu_moved_on_a", name: "read", arguments: { path: "a" } }; const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA]), REPLY, HOLD]); - - await runTurn(createClaudeCodeStreamFunction(runClaude), [HI]); - + const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(getModel(), normalizeContext({ messages: [HI], tools: [READ_TOOL] }))); + assert.match(getErrorMessage(events) ?? "", /moved on without admitting/); + assert.equal(events.some((event) => event.type === "toolcall_end"), false); await assertCleanedUp(getOnlyCall(calls)); }); -test("an undeclared tool call is not kept waiting, since the CLI answers it itself", async (t) => { +test("unadmitted tool intent never becomes an executable call", async (t) => { const readA: ToolUse = { id: "toolu_undeclared_a", name: "read", arguments: { path: "a" } }; const nope: ToolUse = { id: "toolu_undeclared_nope", name: "nope", arguments: {} }; - const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA, nope]), HOLD]); - - const toolMessage = await runTurn(createClaudeCodeStreamFunction(runClaude), [HI]); - - assert.equal(toolMessage.stopReason, "toolUse"); + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([readA, nope]), exitWith(1, "tool denied")]); + const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(getModel(), normalizeContext({ messages: [HI], tools: [READ_TOOL] }))); + assert.equal(events.some((event) => event.type === "toolcall_end"), false); + assert.match(getErrorMessage(events) ?? "", /exited with code 1/); await assertCleanedUp(getOnlyCall(calls)); }); -test("a tool call with invalid JSON arguments is not kept waiting, since the CLI answers it itself", async (t) => { +test("a CLI-rejected malformed call is not exposed for execution", async (t) => { const { runClaude, calls } = createFakeRun(t, [ streamEvent({ type: "message_start", message: { usage: {} } }), streamEvent({ @@ -724,12 +721,13 @@ test("a tool call with invalid JSON arguments is not kept waiting, since the CLI streamEvent({ type: "content_block_stop", index: 0 }), streamEvent({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: {} }), streamEvent({ type: "message_stop" }), + JSON.stringify({ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "toolu_bad_json", is_error: true, content: "invalid arguments" }] } }), HOLD, ]); - const toolMessage = await runTurn(createClaudeCodeStreamFunction(runClaude), [HI]); - - assert.equal(toolMessage.stopReason, "toolUse"); + const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(getModel(), normalizeContext({ messages: [HI], tools: [READ_TOOL] }))); + assert.equal(events.some((event) => event.type === "toolcall_end"), false); + assert.match(getErrorMessage(events) ?? "", /refused.*before MCP admission/); await assertCleanedUp(getOnlyCall(calls)); }); @@ -805,9 +803,140 @@ test("a tool result arriving late but before the idle timeout still continues th await assertCleanedUp(getOnlyCall(calls)); }); -test("two concurrent streams keep separate processes and results never cross", async (t) => { +test("CLI denial cannot execute a tool in the native Pi loop", async (t) => { + const read: ToolUse = { id: "toolu_denied", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([read]), JSON.stringify({ type: "user", message: { content: [ + { type: "tool_result", tool_use_id: read.id, is_error: true, content: "Denied by managed policy" }, + ] } }), HOLD]); + let executed = 0; + const agent = new Agent({ initialState: { model: getModel(), tools: [{ + name: "read", label: "Read", description: "Read", parameters: Type.Object({ path: Type.String() }), + execute: async () => { executed++; return { content: [{ type: "text", text: "must not run" }], details: null }; }, + }] }, streamFn: createClaudeCodeStreamFunction(runClaude) }); + await agent.prompt("hi"); + assert.equal(executed, 0); + const last = agent.state.messages.at(-1); + assert.ok(last?.role === "assistant"); + assert.equal(last.stopReason, "error"); + assert.match(last.errorMessage ?? "", /refused.*before MCP admission/); + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("pre-aborted requests do not spawn a process", async () => { + const controller = new AbortController(); + controller.abort(); + let spawned = false; + const stream = createClaudeCodeStreamFunction(() => { spawned = true; return null; }); + const events = await collectEvents(stream(getModel(), normalizeContext({ messages: [HI] }), { signal: controller.signal })); + assert.equal(spawned, false); + assert.match(getErrorMessage(events) ?? "", /aborted/); +}); + +test("missing MCP identity fails closed without emitting executable calls", async (t) => { + const read: ToolUse = { id: "toolu_no_meta", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([read]), JSON.stringify({ fake: "call", name: read.name, arguments: read.arguments })]); + const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(getModel(), normalizeContext({ messages: [HI], tools: [READ_TOOL] }))); + assert.equal(events.some((event) => event.type === "toolcall_end"), false); + assert.match(getErrorMessage(events) ?? "", /compatibility error.*claudecode\/toolUseId/); + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("a parallel identity failure after an admitted slice is reported on continuation, not hidden by replay", async (t) => { + const first: ToolUse = { id: "toolu_good_parallel", name: "read", arguments: { path: "a" } }; + const second: ToolUse = { id: "toolu_bad_parallel", name: "read", arguments: { path: "b" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([first, second]), + JSON.stringify({ fake: "start-call", id: first.id, name: first.name, arguments: first.arguments }), + JSON.stringify({ fake: "wait-for-message" }), + JSON.stringify({ fake: "call", name: second.name, arguments: second.arguments }), HOLD]); + const stream = createClaudeCodeStreamFunction(runClaude); + const message = await runTurn(stream, [HI]); + getOnlyCall(calls).child.send("continue"); + await waitForExit(getOnlyCall(calls).child); + const events = await collectEvents(stream(getModel(), normalizeContext({ systemPrompt: "You are a tutor.", messages: [HI, message, toToolResult(first, "A")], tools: [READ_TOOL] }))); + assert.match(getErrorMessage(events) ?? "", /compatibility error.*claudecode\/toolUseId/); + assert.equal(calls.length, 1, "must not hide the compatibility failure by spawning again"); + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("output after MCP admission fails continuation instead of silently replaying", async (t) => { + const call: ToolUse = { id: "toolu_moved_on", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([call]), + JSON.stringify({ fake: "start-call", id: call.id, name: call.name, arguments: call.arguments }), + JSON.stringify({ fake: "wait-for-message" }), ...toToolUseMessage([call]), HOLD]); + const stream = createClaudeCodeStreamFunction(runClaude); + const message = await runTurn(stream, [HI]); + getOnlyCall(calls).child.send("continue"); + await waitForExit(getOnlyCall(calls).child); + const events = await collectEvents(stream(getModel(), normalizeContext({ systemPrompt: "You are a tutor.", messages: [HI, message, toToolResult(call, "A")], tools: [READ_TOOL] }))); + assert.match(getErrorMessage(events) ?? "", /moved on while.*waiting for results/); + assert.equal(calls.length, 1); + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("normal completion escalates termination when the child ignores SIGTERM", async (t) => { + const { events, calls } = await runScenario(t, [JSON.stringify({ fake: "ignore-term" }), ...readFixtureLines("text.jsonl"), HOLD]); + assert.equal(getLastEvent(events).type, "done"); + await assertCleanedUp(getOnlyCall(calls)); + assert.equal(getOnlyCall(calls).child.signalCode, "SIGKILL"); +}); + +test("a synchronous spawn failure removes both temp directories and the bridge", async () => { + let resources: { cwd: string; privateDir: string; url: string } | undefined; + const stream = createClaudeCodeStreamFunction((args, options) => { + const config = JSON.parse(readFileSync(getFlagValue(args, "--mcp-config"), "utf8")); + resources = { cwd: options.cwd, privateDir: dirname(getFlagValue(args, "--mcp-config")), url: config.mcpServers.learnharness.url }; + throw new Error("synthetic spawn failure"); + }); + const events = await collectEvents(stream(getModel(), normalizeContext({ messages: [HI] }))); + assert.match(getErrorMessage(events) ?? "", /failed to start/); + assert.ok(resources); + assert.equal(existsSync(resources.cwd), false); + assert.equal(existsSync(resources.privateDir), false); + await assert.rejects(fetch(resources.url)); +}); + +test("a malformed JSON event shape fails the turn rather than escaping the readline handler", async (t) => { + const { events, calls } = await runScenario(t, [JSON.stringify({ type: "assistant", error: "bad", message: { content: [null] } }), HOLD]); + assert.match(getErrorMessage(events) ?? "", /malformed stream output/); + await assertCleanedUp(getOnlyCall(calls)); +}); + +test("a mismatched MCP identity cannot admit a streamed call", async (t) => { + const read: ToolUse = { id: "toolu_intent", name: "read", arguments: { path: "a" } }; + const { runClaude, calls } = createFakeRun(t, [...toToolUseMessage([read]), callTool({ ...read, id: "toolu_someone_else" })]); + const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(getModel(), normalizeContext({ messages: [HI], tools: [READ_TOOL] }))); + assert.equal(events.some((event) => event.type === "toolcall_end"), false); + assert.match(getErrorMessage(events) ?? "", /does not match its streamed tool intent/); + await assertCleanedUp(getOnlyCall(calls)); +}); + +for (const change of ["model", "effort", "system prompt", "tool schema", "tool description", "history"] as const) { + test(`a changed ${change} prevents reuse of a held process`, async (t) => { + const read: ToolUse = { id: `toolu_reuse_${change}`, name: "read", arguments: { path: "a" } }; + const waiting = createFakeRun(t, [...toToolUseMessage([read]), callTool(read), REPLY]); + const fresh = createFakeRun(t, readFixtureLines("text.jsonl")); + const stream = createClaudeCodeStreamFunction((args, options) => (waiting.calls.length === 0 ? waiting : fresh).runClaude(args, options)); + const message = await runTurn(stream, [HI]); + const messages = [change === "history" ? { ...HI, content: "replacement history after compaction" } : HI, message, toToolResult(read, "A")]; + const context = normalizeContext({ + systemPrompt: change === "system prompt" ? "Changed tutor instructions" : "You are a tutor.", + messages, + tools: [{ ...READ_TOOL, + ...(change === "tool schema" ? { parameters: { type: "object", properties: { path: { type: "string" } } } } : {}), + ...(change === "tool description" ? { description: "Changed description" } : {}), + }], + }); + const model = change === "model" ? CLAUDE_CODE_MODELS[1]! : getModel(); + const events = await collectEvents(stream(model, context, change === "effort" ? { reasoning: "high" } : undefined)); + assert.equal(getLastEvent(events).type, "done"); + assert.equal(fresh.calls.length, 1, "must start a fresh CLI rather than silently use stale configuration"); + await assertCleanedUp(getOnlyCall(waiting.calls)); + }); +} + +for (const sharedIdentity of [false, true]) test(`two concurrent stream owners keep separate processes${sharedIdentity ? " even with identical call IDs and arguments" : ""}`, async (t) => { const readOne: ToolUse = { id: "toolu_concurrent_one", name: "read", arguments: { path: "one" } }; - const readTwo: ToolUse = { id: "toolu_concurrent_two", name: "read", arguments: { path: "two" } }; + const readTwo: ToolUse = sharedIdentity ? { ...readOne } : { id: "toolu_concurrent_two", name: "read", arguments: { path: "two" } }; const one = createFakeRun(t, [...toToolUseMessage([readOne]), callTool(readOne), REPLY]); const two = createFakeRun(t, [...toToolUseMessage([readTwo]), callTool(readTwo), REPLY]); diff --git a/tests/claude-code-tool-bridge.test.ts b/tests/claude-code-tool-bridge.test.ts index 3d18881..9b44dfb 100644 --- a/tests/claude-code-tool-bridge.test.ts +++ b/tests/claude-code-tool-bridge.test.ts @@ -107,13 +107,13 @@ test("tools/call stays pending until resolved and returns its text and image con t.after(() => client.close()); let settled = false; - const pending = client.callTool({ name: "read", arguments: { path: "notes.md" } }).finally(() => { + const pending = client.callTool({ name: "read", arguments: { path: "notes.md" }, _meta: { "claudecode/toolUseId": "call-1" } }).finally(() => { settled = true; }); const [call] = await bridge.waitForToolCalls(1); assert.ok(call); - assert.deepEqual(call, { name: "read", arguments: { path: "notes.md" } }); + assert.deepEqual(call, { id: "call-1", name: "read", arguments: { path: "notes.md" } }); assert.deepEqual(bridge.getPendingToolCalls(), [call]); await new Promise((resolve) => setTimeout(resolve, 20)); @@ -147,15 +147,15 @@ test("waitForToolCalls resolves once that many calls are pending, in arrival ord waited = true; }); - void client.callTool({ name: "read", arguments: { path: "a.md" } }).catch(() => {}); + void client.callTool({ name: "read", arguments: { path: "a.md" }, _meta: { "claudecode/toolUseId": "call-1" } }).catch(() => {}); await new Promise((resolve) => setTimeout(resolve, 20)); assert.equal(waited, false); - void client.callTool({ name: "screenshot", arguments: {} }).catch(() => {}); + void client.callTool({ name: "screenshot", arguments: {}, _meta: { "claudecode/toolUseId": "call-2" } }).catch(() => {}); assert.deepEqual(await waiting, [ - { name: "read", arguments: { path: "a.md" } }, - { name: "screenshot", arguments: {} }, + { id: "call-1", name: "read", arguments: { path: "a.md" } }, + { id: "call-2", name: "screenshot", arguments: {} }, ]); }); @@ -165,7 +165,7 @@ test("isError result becomes MCP isError true", async (t) => { const client = await connectClient(bridge); t.after(() => client.close()); - const pending = client.callTool({ name: "read", arguments: { path: "missing.md" } }); + const pending = client.callTool({ name: "read", arguments: { path: "missing.md" }, _meta: { "claudecode/toolUseId": "call-1" } }); const [call] = await bridge.waitForToolCalls(1); assert.ok(call); @@ -180,7 +180,7 @@ test("close fails pending calls and frees the port", async () => { const bridge = await createToolBridge([READ_TOOL]); const port = Number(new URL(getServerConfig(bridge).url).port); const client = await connectClient(bridge); - const pending = client.callTool({ name: "read", arguments: { path: "notes.md" } }); + const pending = client.callTool({ name: "read", arguments: { path: "notes.md" }, _meta: { "claudecode/toolUseId": "call-1" } }); await bridge.waitForToolCalls(1); await bridge.close(); @@ -219,6 +219,25 @@ test('mcpConfig is {"mcpServers":{"learnharness":{"type":"http","url":...,"heade assert.match(server.headers.Authorization, /^Bearer [0-9a-f]{64}$/); }); +test("missing and reused Claude tool IDs are rejected rather than matched by arguments", async (t) => { + const failures: string[] = []; + const bridge = await createToolBridge([READ_TOOL], { onCall: () => {}, onError: (error) => failures.push(error.message) }); + t.after(() => bridge.close()); + const client = await connectClient(bridge); + t.after(() => client.close()); + await assert.rejects(client.callTool({ name: "read", arguments: { path: "a" } }), /claudecode\/toolUseId/); + assert.deepEqual(bridge.getPendingToolCalls(), []); + const params = { name: "read", arguments: { path: "a" }, _meta: { "claudecode/toolUseId": "same-id" } }; + const first = client.callTool(params); + const [call] = await bridge.waitForToolCalls(1); + assert.ok(call); + bridge.resolveToolCall(call, toolResult([{ type: "text", text: "first" }])); + await first; + await assert.rejects(client.callTool(params), /claudecode\/toolUseId/); + assert.deepEqual(bridge.getPendingToolCalls(), []); + assert.equal(failures.length, 2); +}); + test("toolNameFromMcp strips the bridge prefix", () => { assert.equal(toolNameFromMcp("mcp__learnharness__read"), "read"); assert.equal(toolNameFromMcp("read"), "read"); diff --git a/tests/fixtures/claude-code/fake-claude.mjs b/tests/fixtures/claude-code/fake-claude.mjs index cb64d65..d1fef86 100644 --- a/tests/fixtures/claude-code/fake-claude.mjs +++ b/tests/fixtures/claude-code/fake-claude.mjs @@ -4,11 +4,15 @@ * line. Lines with a "fake" key are directives instead of output: * {"fake":"hold"} stay alive until killed, like the CLI waiting on a held MCP call * {"fake":"exit","code":1,"stderr":"..."} write stderr and exit with that code - * {"fake":"call","name":"read","arguments":{...}} call that tool on the MCP bridge and wait for its result + * {"fake":"call","id":"toolu_...","name":"read","arguments":{...}} call the MCP bridge and wait for its result + * {"fake":"start-call",...} start the same request without waiting for its result + * {"fake":"wait-for-message"} pause until the test releases the child over IPC + * {"fake":"ignore-term"} ignore SIGTERM to exercise kill escalation * {"fake":"reply"} stream one assistant message whose text is every result so far, joined by " | " * FAKE_CLAUDE_STDIN, when set, receives the stdin it read. */ import { readFileSync, writeFileSync } from "node:fs"; +import { once } from "node:events"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; @@ -49,10 +53,15 @@ if (process.env.FAKE_CLAUDE_STDIN) writeFileSync(process.env.FAKE_CLAUDE_STDIN, let bridge = null; const results = []; +const pendingResults = []; const lines = readFileSync(process.env.FAKE_CLAUDE_SCENARIO, "utf8").split("\n").filter(Boolean); for (const line of lines) { const directive = JSON.parse(line); + if (directive.fake === "wait-for-message") { + await once(process, "message"); + continue; + } if (directive.fake === "hold") { setInterval(() => {}, HOLD_INTERVAL_MS); break; @@ -63,14 +72,28 @@ for (const line of lines) { process.exit(directive.code); } - if (directive.fake === "call") { + if (directive.fake === "ignore-term") { + process.on("SIGTERM", () => {}); + continue; + } + if (directive.fake === "call" || directive.fake === "start-call") { bridge ??= await connectBridge(mcpConfig); - const result = await bridge.callTool({ name: directive.name, arguments: directive.arguments }); - results.push(result.content.map((block) => block.text).join("")); + const request = bridge.callTool({ + name: directive.name, + arguments: directive.arguments, + ...(directive.id !== undefined ? { _meta: { "claudecode/toolUseId": directive.id } } : {}), + }); + if (directive.fake === "start-call") { + pendingResults.push(request); + void request.catch(() => {}); + } else { + const result = await request; + results.push(result.content.map((block) => block.text).join("")); + } continue; - } if (directive.fake === "reply") { + for (const result of await Promise.all(pendingResults.splice(0))) results.push(result.content.map((block) => block.text).join("")); await writeReply(results.join(" | ")); continue; } diff --git a/tests/send-to-user.test.ts b/tests/send-to-user.test.ts index beaeae3..80109d4 100644 --- a/tests/send-to-user.test.ts +++ b/tests/send-to-user.test.ts @@ -88,6 +88,9 @@ describe("send_to_user model gate", () => { ["openrouter", "anthropic/claude-sonnet-5.5", true], ["amazon-bedrock", "us.anthropic.claude-opus-5-5-v1:0", true], ["fixture", "CLAUDE-fixture", true], + ["claude-code", "opus", true], + ["claude-code", "sonnet", true], + ["claude-code", "haiku", true], ["openrouter", "z-ai/glm-5.3-flash", false], ["openai", "gpt-4o-mini", false], ["anthropic", "opaque-alias", false], From 64a36bebd2ebd4b5806c7aeeea53b30aeab8415d Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 17:19:51 +0200 Subject: [PATCH 66/87] fix: scope held Claude processes and honor MCP cancellation --- src/agent/claude-code/live-processes.ts | 8 ++-- src/agent/claude-code/stream.ts | 34 +++++++++----- src/agent/claude-code/tool-bridge.ts | 29 +++++++++--- tests/claude-code-harness.test.ts | 42 +++++++++++++++-- tests/claude-code-stream.test.ts | 53 ++++++++++++++++++++-- tests/claude-code-tool-bridge.test.ts | 27 +++++++++++ tests/fixtures/claude-code/fake-claude.mjs | 36 ++++++++++++--- 7 files changed, 192 insertions(+), 37 deletions(-) diff --git a/src/agent/claude-code/live-processes.ts b/src/agent/claude-code/live-processes.ts index 1a68672..ebb729b 100644 --- a/src/agent/claude-code/live-processes.ts +++ b/src/agent/claude-code/live-processes.ts @@ -1,4 +1,4 @@ -export type LiveProcess = { readonly owner: symbol; toolCallIds: string[]; kill(): void }; +export type LiveProcess = { readonly owner: symbol; readonly sessionId: string | undefined; toolCallIds: string[]; kill(): void }; // Tools usually call back within seconds, but a question tool holds its call until the learner // answers. Past this, the process is killed and the answer starts a fresh one with a full replay. @@ -27,11 +27,11 @@ function hasSameIds(ids: string[], otherIds: string[]): boolean { return ids.length === otherIds.length && new Set(otherIds).size === otherIds.length && ids.every((id) => otherIds.includes(id)); } -export function getLiveProcess(toolCallIds: string[], owner: symbol): LiveProcess | null { - if (toolCallIds.length === 0) return null; +export function getLiveProcess(toolCallIds: string[], owner: symbol, sessionId: string | undefined): LiveProcess | null { + if (!sessionId || toolCallIds.length === 0) return null; for (const process of liveProcesses.keys()) { - if (process.owner === owner && hasSameIds(process.toolCallIds, toolCallIds)) return process; + if (process.owner === owner && process.sessionId === sessionId && hasSameIds(process.toolCallIds, toolCallIds)) return process; } return null; diff --git a/src/agent/claude-code/stream.ts b/src/agent/claude-code/stream.ts index cd27167..8c9c89a 100644 --- a/src/agent/claude-code/stream.ts +++ b/src/agent/claude-code/stream.ts @@ -392,6 +392,7 @@ class ClaudeProcess implements LiveProcess { private sourceCalls: ToolCall[] = []; private sourceComplete = false; private admitted: PendingToolCall[] = []; + private readonly cancelledIds = new Set(); private pendingError: string | undefined; constructor( @@ -400,6 +401,7 @@ class ClaudeProcess implements LiveProcess { private readonly files: TurnFiles, private turn: ClaudeTurn, readonly owner: symbol, + readonly sessionId: string | undefined, private readonly configuration: unknown, private conversation: unknown[], ) {} @@ -422,9 +424,9 @@ class ClaudeProcess implements LiveProcess { this.setSignal(signal); } - canContinue(owner: symbol, configuration: unknown, messages: Message[], results: ToolResultMessage[]): boolean { + canContinue(owner: symbol, sessionId: string | undefined, configuration: unknown, messages: Message[], results: ToolResultMessage[]): boolean { const conversation = conversationForReuse(messages); - return !this.stopping && owner === this.owner + return !this.stopping && owner === this.owner && !!sessionId && sessionId === this.sessionId && isDeepStrictEqual(configuration, this.configuration) && isDeepStrictEqual(conversation.slice(0, this.conversation.length), this.conversation) && conversation.length === this.conversation.length + this.admitted.length @@ -462,7 +464,7 @@ class ClaudeProcess implements LiveProcess { // A parallel request can fail after the admitted slice was handed to // Pi. Retain that failure for its continuation instead of silently // replaying and hiding the compatibility error. - if (this.turn.finished && this.toolCallIds.length > 0) this.pendingError = message; + if (this.sessionId && this.turn.finished && this.toolCallIds.length > 0) this.pendingError = message; this.turn.fail(message, "error"); this.kill(); if (this.pendingError) addLiveProcess(this); @@ -477,6 +479,13 @@ class ClaudeProcess implements LiveProcess { return true; } + cancelCall(call: PendingToolCall): void { + this.cancelledIds.add(call.id); + // Already admitted work belongs to Pi and cannot be undone. A queued + // request, however, must never become executable after cancellation. + this.sourceCalls = this.sourceCalls.filter((intent) => intent.id !== call.id); + } + /** Only authenticated MCP requests, never stdout intent alone, admit side effects. */ flushAdmissions(): void { if (this.stopping || this.turn.finished || !this.sourceComplete) return; @@ -496,7 +505,8 @@ class ClaudeProcess implements LiveProcess { this.toolCallIds = pending.map((call) => call.id); this.turn.admit(calls); this.conversation.push(...structuredClone(conversationForReuse([this.turn.getMessage()]))); - addLiveProcess(this); + if (this.sessionId) addLiveProcess(this); + else this.kill(); // No trusted scope: return the batch, then use fresh replay. } private readonly abort = () => { @@ -534,11 +544,12 @@ class ClaudeProcess implements LiveProcess { this.turn.handle(event); if (this.turn.finished) { this.kill(); return; } if (event.type === "messageStop" && this.turn.waitingForAdmission) { - this.sourceCalls = this.turn.getIntents(); - if (this.sourceCalls.length === 0 || new Set(this.sourceCalls.map((call) => call.id)).size !== this.sourceCalls.length) { + const intents = this.turn.getIntents(); + if (intents.length === 0 || new Set(intents.map((call) => call.id)).size !== intents.length) { this.fail("claude CLI emitted an invalid tool batch"); return; } + this.sourceCalls = intents.filter((call) => !this.cancelledIds.has(call.id)); this.sourceComplete = true; this.flushAdmissions(); } @@ -559,17 +570,17 @@ class ClaudeProcess implements LiveProcess { } } -function continueLiveProcess(turn: ClaudeTurn, owner: symbol, configuration: unknown, messages: Message[], signal: AbortSignal | undefined): boolean { +function continueLiveProcess(turn: ClaudeTurn, owner: symbol, sessionId: string | undefined, configuration: unknown, messages: Message[], signal: AbortSignal | undefined): boolean { const assistantIndex = messages.findLastIndex((message) => message.role === "assistant"); const assistant = messages[assistantIndex]; if (assistant?.role !== "assistant") return false; const ids = assistant.content.flatMap((block) => block.type === "toolCall" ? [block.id] : []); - const process = getLiveProcess(ids, owner); + const process = getLiveProcess(ids, owner, sessionId); if (!(process instanceof ClaudeProcess)) return false; if (process.consumeFailure(owner, turn)) return true; const trailing = messages.slice(assistantIndex + 1); const results = trailing.filter((message) => message.role === "toolResult"); - if (trailing.length !== results.length || !process.canContinue(owner, configuration, messages, results)) { + if (trailing.length !== results.length || !process.canContinue(owner, sessionId, configuration, messages, results)) { process.kill(); return false; } @@ -604,6 +615,7 @@ async function startProcess( const bridge = await createToolBridge(input.tools, { onCall: () => cliProcess?.flushAdmissions(), onError: (error) => cliProcess?.fail(error.message), + onCancel: (call) => cliProcess?.cancelCall(call), }); let files: TurnFiles; try { @@ -639,7 +651,7 @@ async function startProcess( return; } - cliProcess = new ClaudeProcess(child, bridge, files, turn, owner, configuration, structuredClone(conversationForReuse(context.messages))); + cliProcess = new ClaudeProcess(child, bridge, files, turn, owner, options?.sessionId, configuration, structuredClone(conversationForReuse(context.messages))); cliProcess.start(toStreamJsonLine(input.userContent), options?.signal); } @@ -650,7 +662,7 @@ export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunc const turn = new ClaudeTurn(stream, model); const configuration = configurationForReuse(model, context, toEffort(options?.reasoning)); - if (continueLiveProcess(turn, owner, configuration, context.messages, options?.signal)) return stream; + if (continueLiveProcess(turn, owner, options?.sessionId, configuration, context.messages, options?.signal)) return stream; startProcess(runClaude, turn, model, context, options, owner, configuration).catch((error: unknown) => { console.error("claude CLI turn failed:", error); diff --git a/src/agent/claude-code/tool-bridge.ts b/src/agent/claude-code/tool-bridge.ts index db9f4da..71dc6de 100644 --- a/src/agent/claude-code/tool-bridge.ts +++ b/src/agent/claude-code/tool-bridge.ts @@ -22,7 +22,7 @@ const TOKEN_BYTES = 32; export type PendingToolCall = { id: string; name: string; arguments: JsonObject }; -type BridgeCallbacks = { onCall(): void; onError(error: Error): void }; +type BridgeCallbacks = { onCall(): void; onError(error: Error): void; onCancel?(call: PendingToolCall): void }; export const MCP_IDENTITY_ERROR = "claude CLI compatibility error: MCP calls must include a unique claudecode/toolUseId"; @@ -67,8 +67,22 @@ class ToolCallQueue { private readonly results = new Map void>(); private waiters: ToolCallWaiter[] = []; - add(call: PendingToolCall): Promise { - const result = new Promise((resolve) => this.results.set(call, resolve)); + add(call: PendingToolCall, signal: AbortSignal, onCancel?: (call: PendingToolCall) => void): Promise { + const result = new Promise((resolve) => { + const cancel = () => { + const settle = this.results.get(call); + if (!settle) return; + settle({ content: [{ type: "text", text: "MCP request cancelled" }], isError: true }); + onCancel?.(call); + }; + this.results.set(call, (result) => { + this.results.delete(call); + signal.removeEventListener("abort", cancel); + resolve(result); + }); + signal.addEventListener("abort", cancel, { once: true }); + if (signal.aborted) cancel(); + }); this.wakeWaiters(); @@ -84,7 +98,6 @@ class ToolCallQueue { resolve(call: PendingToolCall, result: ToolResultMessage): void { this.results.get(call)?.(toCallToolResult(result)); - this.results.delete(call); } list(): PendingToolCall[] { @@ -92,7 +105,9 @@ class ToolCallQueue { } clear(): void { - this.results.clear(); + for (const settle of this.results.values()) { + settle({ content: [{ type: "text", text: "MCP bridge closed" }], isError: true }); + } for (const waiter of this.waiters) waiter.reject(new Error("claude-code tool bridge closed")); this.waiters = []; @@ -116,7 +131,7 @@ function createMcpServer(tools: Tool[], queue: ToolCallQueue, callbacks?: Bridge server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: tools.map(toMcpTool) })); const seenIds = new Set(); - server.setRequestHandler(CallToolRequestSchema, (request) => { + server.setRequestHandler(CallToolRequestSchema, (request, extra) => { const id = request.params._meta?.["claudecode/toolUseId"]; if (typeof id !== "string" || !id.trim() || seenIds.has(id)) { const error = new McpError(ErrorCode.InvalidParams, MCP_IDENTITY_ERROR); @@ -129,7 +144,7 @@ function createMcpServer(tools: Tool[], queue: ToolCallQueue, callbacks?: Bridge throw error; } seenIds.add(id); - const result = queue.add({ id, name: request.params.name, arguments: (request.params.arguments ?? {}) as JsonObject }); + const result = queue.add({ id, name: request.params.name, arguments: (request.params.arguments ?? {}) as JsonObject }, extra.signal, callbacks?.onCancel); callbacks?.onCall(); return result; }); diff --git a/tests/claude-code-harness.test.ts b/tests/claude-code-harness.test.ts index d300b35..aab05bb 100644 --- a/tests/claude-code-harness.test.ts +++ b/tests/claude-code-harness.test.ts @@ -78,9 +78,9 @@ function createFakeRun(t: TestContext, scenarios: string[][]): FakeRun { const child = spawn(process.execPath, [FAKE_CLAUDE, ...args], { cwd: options.cwd, env: { ...options.env, FAKE_CLAUDE_SCENARIO: scenarioFile, FAKE_CLAUDE_STDIN: stdinFile }, - stdio: "pipe", - }); - t.after(() => child.kill()); + stdio: ["pipe", "pipe", "pipe", "ipc"], + }) as ChildProcessWithoutNullStreams; + t.after(() => child.kill("SIGKILL")); children.push(child); stdinFiles.push(stdinFile); @@ -104,7 +104,7 @@ function createAgentFactory(fakeRun: FakeRun): Parameters createTeacherAgent(undefined, { engine, messages: options.messages }); + return (options) => createTeacherAgent(undefined, { engine, chatId: options.chatId, messages: options.messages }); } async function runChatTurn(chatId: string, input: object, agentFactory: Parameters[1]): Promise { @@ -196,6 +196,40 @@ test("a question pauses later calls from the same CLI response until the learner await waitForExit(fakeRun.children[0]!); }); +test("an individually cancelled MCP question queued behind a learner wait never executes", async (t) => { + const cancelledId = "toolu_cancelled_question"; + const question = "This cancelled question must never be posed"; + const scenario = toAskScenario(); + scenario.splice(4, 0, + streamEvent({ type: "content_block_start", index: 1, content_block: { type: "tool_use", id: cancelledId, name: "mcp__learnharness__ask_user_question", input: {} } }), + streamEvent({ type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: JSON.stringify({ question }) } }), + streamEvent({ type: "content_block_stop", index: 1 }), + ); + scenario.splice(scenario.length - 2, 2, + JSON.stringify({ fake: "start-call", id: ASK_ID, name: "ask_user_question", arguments: ASK_INPUT }), + JSON.stringify({ fake: "wait-for-message" }), + JSON.stringify({ fake: "start-call", id: cancelledId, name: "ask_user_question", arguments: { question } }), + JSON.stringify({ fake: "cancel-call", id: cancelledId }), + JSON.stringify({ fake: "checkpoint" }), + JSON.stringify({ fake: "reply" }), + ); + const fakeRun = createFakeRun(t, [scenario]); + const factory = createAgentFactory(fakeRun); + const chatId = createClaudeCodeChat(t); + await runChatTurn(chatId, { type: "text", text: "teach me sockets" }, factory); + assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? [])?.toolCallId, ASK_ID); + const child = fakeRun.children[0]!; + const cancelledOnServer = once(child, "message", { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) }); + child.send("queue and cancel B"); + assert.deepEqual((await cancelledOnServer)[0], { fake: "checkpoint" }); + await runChatTurn(chatId, toAnswerInput(), factory); + assert.equal(fakeRun.children.length, 1); + assert.equal(danglingQuestionOf(chatStore.getChat(chatId)?.messages ?? []), null); + assert.equal(JSON.stringify(chatStore.getChat(chatId)?.messages).includes(cancelledId), false, "cancelled B must never reach native tool execution or persistence"); + assert.ok(getLastAssistantText(chatId).includes(ANSWER_TEXT)); + await waitForExit(child); +}); + test("a question answered after the idle timeout starts a fresh CLI process that replays the question and answer", async (t) => { liveProcessIdleTimeoutMs.current = 300; t.after(() => (liveProcessIdleTimeoutMs.current = LIVE_PROCESS_IDLE_TIMEOUT_MS)); diff --git a/tests/claude-code-stream.test.ts b/tests/claude-code-stream.test.ts index efdd322..0a61add 100644 --- a/tests/claude-code-stream.test.ts +++ b/tests/claude-code-stream.test.ts @@ -45,6 +45,7 @@ const HOLD = JSON.stringify({ fake: "hold" }); const REPLY = JSON.stringify({ fake: "reply" }); const CLEANUP_TIMEOUT_MS = 2000; +const SESSION_ID = "stream-test-chat"; const READ_TOOL: Tool = { name: "read", description: "Read a file", parameters: { type: "object", properties: {} } }; const HI: Message = { role: "user", content: "hi", timestamp: 0 }; @@ -156,7 +157,7 @@ async function runScenario( messages: [{ role: "user", content: "hi", timestamp: 0 }], tools: [READ_TOOL, { name: "get_weather", description: "Weather", parameters: { type: "object", properties: {} } }], }); - const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(model, context, options)); + const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(model, context, { sessionId: SESSION_ID, ...options })); return { events, calls }; } @@ -210,7 +211,7 @@ async function runTurn( ): Promise { const context = normalizeContext({ systemPrompt: "You are a tutor.", messages, tools: [READ_TOOL] }); - const done = getLastEvent(await collectEvents(streamFunction(getModel(), context, options))); + const done = getLastEvent(await collectEvents(streamFunction(getModel(), context, { sessionId: SESSION_ID, ...options }))); assert.ok(done.type === "done", `turn ended with ${done.type}`); @@ -852,7 +853,7 @@ test("a parallel identity failure after an admitted slice is reported on continu const message = await runTurn(stream, [HI]); getOnlyCall(calls).child.send("continue"); await waitForExit(getOnlyCall(calls).child); - const events = await collectEvents(stream(getModel(), normalizeContext({ systemPrompt: "You are a tutor.", messages: [HI, message, toToolResult(first, "A")], tools: [READ_TOOL] }))); + const events = await collectEvents(stream(getModel(), normalizeContext({ systemPrompt: "You are a tutor.", messages: [HI, message, toToolResult(first, "A")], tools: [READ_TOOL] }), { sessionId: SESSION_ID })); assert.match(getErrorMessage(events) ?? "", /compatibility error.*claudecode\/toolUseId/); assert.equal(calls.length, 1, "must not hide the compatibility failure by spawning again"); await assertCleanedUp(getOnlyCall(calls)); @@ -867,7 +868,7 @@ test("output after MCP admission fails continuation instead of silently replayin const message = await runTurn(stream, [HI]); getOnlyCall(calls).child.send("continue"); await waitForExit(getOnlyCall(calls).child); - const events = await collectEvents(stream(getModel(), normalizeContext({ systemPrompt: "You are a tutor.", messages: [HI, message, toToolResult(call, "A")], tools: [READ_TOOL] }))); + const events = await collectEvents(stream(getModel(), normalizeContext({ systemPrompt: "You are a tutor.", messages: [HI, message, toToolResult(call, "A")], tools: [READ_TOOL] }), { sessionId: SESSION_ID })); assert.match(getErrorMessage(events) ?? "", /moved on while.*waiting for results/); assert.equal(calls.length, 1); await assertCleanedUp(getOnlyCall(calls)); @@ -927,13 +928,55 @@ for (const change of ["model", "effort", "system prompt", "tool schema", "tool d }], }); const model = change === "model" ? CLAUDE_CODE_MODELS[1]! : getModel(); - const events = await collectEvents(stream(model, context, change === "effort" ? { reasoning: "high" } : undefined)); + const events = await collectEvents(stream(model, context, { sessionId: SESSION_ID, ...(change === "effort" ? { reasoning: "high" as const } : {}) })); assert.equal(getLastEvent(events).type, "done"); assert.equal(fresh.calls.length, 1, "must start a fresh CLI rather than silently use stale configuration"); await assertCleanedUp(getOnlyCall(waiting.calls)); }); } +for (const identicalHistory of [false, true]) test(`one registered stream isolates two sessions with colliding tool IDs and ${identicalHistory ? "identical" : "different"} histories`, async (t) => { + const call: ToolUse = { id: "toolu_shared_singleton", name: "read", arguments: { path: "same" } }; + const one = createFakeRun(t, [...toToolUseMessage([call]), callTool(call), REPLY]); + const two = createFakeRun(t, [...toToolUseMessage([call]), callTool(call), REPLY]); + let spawns = 0; + const stream = createClaudeCodeStreamFunction((args, options) => { + const run = [one, two][spawns++]; + assert.ok(run, "a valid continuation must not spawn another CLI"); + return run.runClaude(args, options); + }); + const historyOne = [HI]; + const historyTwo: Message[] = identicalHistory ? [HI] : [{ role: "user", content: "another conversation", timestamp: 0 }]; + const messageOne = await runTurn(stream, historyOne, { sessionId: "chat-one" }); + const messageTwo = await runTurn(stream, historyTwo, { sessionId: "chat-two" }); + // B returns first while A is earlier in the registry: IDs and even full + // history must not let B resolve A's authenticated MCP request. + const replyTwo = await runTurn(stream, [...historyTwo, messageTwo, toToolResult(call, "from two")], { sessionId: "chat-two" }); + const replyOne = await runTurn(stream, [...historyOne, messageOne, toToolResult(call, "from one")], { sessionId: "chat-one" }); + assert.equal(getText(replyTwo), "from two"); + assert.equal(getText(replyOne), "from one"); + assert.equal(spawns, 2); + await assertCleanedUp(getOnlyCall(one.calls)); + await assertCleanedUp(getOnlyCall(two.calls)); +}); + +test("a caller without a session ID uses fresh replay and retains no held process", async (t) => { + const call: ToolUse = { id: "toolu_no_scope", name: "read", arguments: { path: "a" } }; + const waiting = createFakeRun(t, [...toToolUseMessage([call]), callTool(call), REPLY]); + const stdinDir = mkdtempSync(join(tmpdir(), "claude-unscoped-replay-")); + t.after(() => rmSync(stdinDir, { recursive: true, force: true })); + const stdinFile = join(stdinDir, "stdin.jsonl"); + const fresh = createFakeRun(t, readFixtureLines("text.jsonl"), { FAKE_CLAUDE_STDIN: stdinFile }); + const stream = createClaudeCodeStreamFunction((args, options) => (waiting.calls.length === 0 ? waiting : fresh).runClaude(args, options)); + const message = await runTurn(stream, [HI], { sessionId: undefined }); + await assertCleanedUp(getOnlyCall(waiting.calls)); + const reply = await runTurn(stream, [HI, message, toToolResult(call, "the preserved answer")], { sessionId: undefined }); + assert.equal(reply.stopReason, "stop"); + assert.equal(fresh.calls.length, 1); + assert.match(readFileSync(stdinFile, "utf8"), /the preserved answer/); + await assertCleanedUp(getOnlyCall(fresh.calls)); +}); + for (const sharedIdentity of [false, true]) test(`two concurrent stream owners keep separate processes${sharedIdentity ? " even with identical call IDs and arguments" : ""}`, async (t) => { const readOne: ToolUse = { id: "toolu_concurrent_one", name: "read", arguments: { path: "one" } }; const readTwo: ToolUse = sharedIdentity ? { ...readOne } : { id: "toolu_concurrent_two", name: "read", arguments: { path: "two" } }; diff --git a/tests/claude-code-tool-bridge.test.ts b/tests/claude-code-tool-bridge.test.ts index 9b44dfb..ff66fd9 100644 --- a/tests/claude-code-tool-bridge.test.ts +++ b/tests/claude-code-tool-bridge.test.ts @@ -136,6 +136,33 @@ test("tools/call stays pending until resolved and returns its text and image con assert.deepEqual(bridge.getPendingToolCalls(), []); }); +test("individual MCP cancellation removes only that request and leaves the other request resolvable", async (t) => { + let acknowledgeCancel!: () => void; + const cancelled = new Promise((resolve) => { acknowledgeCancel = resolve; }); + const cancellations: string[] = []; + const bridge = await createToolBridge([READ_TOOL], { + onCall() {}, onError(error) { throw error; }, + onCancel(call) { cancellations.push(call.id); acknowledgeCancel(); }, + }); + t.after(() => bridge.close()); + const client = await connectClient(bridge); + t.after(() => client.close()); + const first = client.callTool({ name: "read", arguments: { path: "a" }, _meta: { "claudecode/toolUseId": "call-a" } }); + const controller = new AbortController(); + const second = client.callTool({ name: "read", arguments: { path: "b" }, _meta: { "claudecode/toolUseId": "call-b" } }, undefined, { signal: controller.signal }); + const rejected = assert.rejects(second, /cancelled B/); + await bridge.waitForToolCalls(2); + controller.abort("cancelled B"); + await cancelled; + await rejected; + assert.deepEqual(cancellations, ["call-b"]); + const pending = bridge.getPendingToolCalls(); + assert.deepEqual(pending.map((call) => call.id), ["call-a"]); + bridge.resolveToolCall(pending[0]!, toolResult([{ type: "text", text: "A" }])); + assert.deepEqual((await first).content, [{ type: "text", text: "A" }]); + assert.deepEqual(bridge.getPendingToolCalls(), []); +}); + test("waitForToolCalls resolves once that many calls are pending, in arrival order", async (t) => { const bridge = await createToolBridge([READ_TOOL, SCREENSHOT_TOOL]); t.after(() => bridge.close()); diff --git a/tests/fixtures/claude-code/fake-claude.mjs b/tests/fixtures/claude-code/fake-claude.mjs index d1fef86..7438444 100644 --- a/tests/fixtures/claude-code/fake-claude.mjs +++ b/tests/fixtures/claude-code/fake-claude.mjs @@ -6,17 +6,20 @@ * {"fake":"exit","code":1,"stderr":"..."} write stderr and exit with that code * {"fake":"call","id":"toolu_...","name":"read","arguments":{...}} call the MCP bridge and wait for its result * {"fake":"start-call",...} start the same request without waiting for its result + * {"fake":"cancel-call","id":"toolu_..."} cancel an individual pending MCP request + * {"fake":"checkpoint"} notify the parent after previous sends finish * {"fake":"wait-for-message"} pause until the test releases the child over IPC * {"fake":"ignore-term"} ignore SIGTERM to exercise kill escalation * {"fake":"reply"} stream one assistant message whose text is every result so far, joined by " | " * FAKE_CLAUDE_STDIN, when set, receives the stdin it read. */ import { readFileSync, writeFileSync } from "node:fs"; -import { once } from "node:events"; +import { on } from "node:events"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +const parentMessages = on(process, "message"); const HOLD_INTERVAL_MS = 60_000; const BRIDGE_SERVER_NAME = "learnharness"; @@ -28,11 +31,16 @@ function streamEvent(event) { return JSON.stringify({ type: "stream_event", event }); } +let lastSend = Promise.resolve(); + async function connectBridge(mcpConfig) { const { url, headers } = JSON.parse(mcpConfig).mcpServers[BRIDGE_SERVER_NAME]; const client = new Client({ name: "fake-claude", version: "1.0.0" }); - await client.connect(new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers } })); + const transport = new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers } }); + const send = transport.send.bind(transport); + transport.send = (...args) => (lastSend = send(...args)); + await client.connect(transport); return client; } @@ -54,12 +62,25 @@ if (process.env.FAKE_CLAUDE_STDIN) writeFileSync(process.env.FAKE_CLAUDE_STDIN, let bridge = null; const results = []; const pendingResults = []; +const controllers = new Map(); +const cancelled = new Set(); const lines = readFileSync(process.env.FAKE_CLAUDE_SCENARIO, "utf8").split("\n").filter(Boolean); for (const line of lines) { const directive = JSON.parse(line); + if (directive.fake === "cancel-call") { + cancelled.add(directive.id); + controllers.get(directive.id)?.abort("cancelled by fake CLI"); + await lastSend; + continue; + } + if (directive.fake === "checkpoint") { + await lastSend; + process.send?.({ fake: "checkpoint" }); + continue; + } if (directive.fake === "wait-for-message") { - await once(process, "message"); + await parentMessages.next(); continue; } if (directive.fake === "hold") { @@ -78,14 +99,17 @@ for (const line of lines) { } if (directive.fake === "call" || directive.fake === "start-call") { bridge ??= await connectBridge(mcpConfig); + const controller = new AbortController(); + controllers.set(directive.id, controller); const request = bridge.callTool({ name: directive.name, arguments: directive.arguments, ...(directive.id !== undefined ? { _meta: { "claudecode/toolUseId": directive.id } } : {}), - }); + }, undefined, { signal: controller.signal }); if (directive.fake === "start-call") { - pendingResults.push(request); + pendingResults.push({ id: directive.id, request }); void request.catch(() => {}); + await lastSend; } else { const result = await request; results.push(result.content.map((block) => block.text).join("")); @@ -93,7 +117,7 @@ for (const line of lines) { continue; } if (directive.fake === "reply") { - for (const result of await Promise.all(pendingResults.splice(0))) results.push(result.content.map((block) => block.text).join("")); + for (const result of await Promise.all(pendingResults.splice(0).filter(({ id }) => !cancelled.has(id)).map(({ request }) => request))) results.push(result.content.map((block) => block.text).join("")); await writeReply(results.join(" | ")); continue; } From 84f93999c54922d327d957beb4c4831fbb0ffbde Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 18:16:04 +0200 Subject: [PATCH 67/87] fix: normalize model-visible images with Pi processing --- next.config.ts | 6 + package-lock.json | 7 + package.json | 1 + scripts/smoke-image-runtime.mjs | 228 +++++++++++ src/agent/image-processing/LICENSE | 21 ++ src/agent/image-processing/convert.d.mts | 1 + src/agent/image-processing/convert.mjs | 25 ++ .../image-processing/exif-orientation.mjs | 157 ++++++++ src/agent/image-processing/index.ts | 59 +++ src/agent/image-processing/mime.d.mts | 3 + src/agent/image-processing/mime.mjs | 110 ++++++ src/agent/image-processing/photon.mjs | 21 ++ src/agent/image-processing/resize-core.d.mts | 10 + src/agent/image-processing/resize-core.mjs | 130 +++++++ src/agent/image-processing/resize-worker.mjs | 32 ++ src/agent/image-processing/resize.ts | 65 ++++ src/agent/image-processing/types.ts | 35 ++ src/agent/subagents/headless.ts | 57 ++- src/agent/subagents/pdf-reader.ts | 29 +- src/agent/subagents/visual-maker.ts | 21 +- src/agent/teacher-agent.ts | 5 +- src/agent/tools/read.ts | 73 ++-- tests/agent-image-paths.test.ts | 351 +++++++++++++++++ tests/demos.test.tsx | 7 +- tests/image-processing.test.ts | 357 ++++++++++++++++++ tests/pdf-native-render.test.ts | 106 +++++- tests/provider-tracing.test.ts | 3 + tests/read-tool.test.ts | 10 +- 28 files changed, 1870 insertions(+), 60 deletions(-) create mode 100644 scripts/smoke-image-runtime.mjs create mode 100644 src/agent/image-processing/LICENSE create mode 100644 src/agent/image-processing/convert.d.mts create mode 100644 src/agent/image-processing/convert.mjs create mode 100644 src/agent/image-processing/exif-orientation.mjs create mode 100644 src/agent/image-processing/index.ts create mode 100644 src/agent/image-processing/mime.d.mts create mode 100644 src/agent/image-processing/mime.mjs create mode 100644 src/agent/image-processing/photon.mjs create mode 100644 src/agent/image-processing/resize-core.d.mts create mode 100644 src/agent/image-processing/resize-core.mjs create mode 100644 src/agent/image-processing/resize-worker.mjs create mode 100644 src/agent/image-processing/resize.ts create mode 100644 src/agent/image-processing/types.ts create mode 100644 tests/agent-image-paths.test.ts create mode 100644 tests/image-processing.test.ts diff --git a/next.config.ts b/next.config.ts index a4236ad..43aef8d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -78,6 +78,11 @@ const nextConfig: NextConfig = { // npm installs only the current platform's binary, so this glob // resolves to exactly one Skia binary per build. "node_modules/@napi-rs/canvas-*/**/*", + // Pi-compatible image resizing runs in an unbundled worker. Photon + // loads its colocated WASM through Node, also in packaged Electron. + "src/agent/image-processing/*.mjs", + "src/agent/image-processing/LICENSE", + "node_modules/@silvia-odwyer/photon-node/**/*", // Wave E stage 2: resvg rasterizes visual-maker SVGs natively; same // per-platform-binary story as the canvas above (one per build). "node_modules/@resvg/resvg-js-*/**/*", @@ -106,6 +111,7 @@ const nextConfig: NextConfig = { "@earendil-works/pi-agent-core", "pdfjs-dist", // sources v0: bundling breaks its node fake-worker path "@napi-rs/canvas", // native binding — bundling breaks the .node require + "@silvia-odwyer/photon-node", // CJS loads its adjacent WASM from disk "@resvg/resvg-js", // native binding — same ], }; diff --git a/package-lock.json b/package-lock.json index 638b775..9b359b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@radix-ui/react-use-controllable-state": "^1.2.6", "@resvg/resvg-js": "^2.6.2", "@rive-app/react-webgl2": "^4.32.1", + "@silvia-odwyer/photon-node": "0.3.4", "@streamdown/cjk": "^1.0.3", "@streamdown/code": "^1.1.1", "@streamdown/math": "^1.0.2", @@ -4717,6 +4718,12 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", diff --git a/package.json b/package.json index 583eabc..2008ba4 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@radix-ui/react-use-controllable-state": "^1.2.6", "@resvg/resvg-js": "^2.6.2", "@rive-app/react-webgl2": "^4.32.1", + "@silvia-odwyer/photon-node": "0.3.4", "@streamdown/cjk": "^1.0.3", "@streamdown/code": "^1.1.1", "@streamdown/math": "^1.0.2", diff --git a/scripts/smoke-image-runtime.mjs b/scripts/smoke-image-runtime.mjs new file mode 100644 index 0000000..c99e18d --- /dev/null +++ b/scripts/smoke-image-runtime.mjs @@ -0,0 +1,228 @@ +/** + * OFFLINE image runtime gate: exercise the shipped Photon/WASM resize core and + * actual worker, with file modules confined to the standalone payload. No app, + * provider, canvas, or network needed; a tiny synthetic PNG is generated here. + * + * node scripts/smoke-image-runtime.mjs [--standalone ] [--runtime ] + */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import { createRequire } from "node:module"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { Worker } from "node:worker_threads"; +import { deflateSync } from "node:zlib"; +import { formatProbeError, probeEnv } from "./smoke-demo-runtime.mjs"; + +export function verifyImageRuntime(standalone, runtime = process.execPath) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-image-runtime-")); + try { + fs.writeFileSync(path.join(home, "auth.json"), "{}\n", { mode: 0o600 }); + const result = spawnSync(runtime, [fileURLToPath(import.meta.url), "--probe", path.resolve(standalone)], { + cwd: home, + env: { ...probeEnv(process.env, home), ELECTRON_RUN_AS_NODE: "1" }, + encoding: "utf8", timeout: 30_000, killSignal: "SIGKILL", maxBuffer: 128 * 1024, + }); + if (result.status !== 0 || result.error) { + throw new Error(`image runtime probe failed (${result.error?.message ?? result.status}):\n${result.stdout ?? ""}${result.stderr ?? ""}`); + } + assert.match(result.stdout, /Photon in-process and worker resizing passed/, "probe exited without success marker"); + process.stdout.write(result.stdout); + } finally { fs.rmSync(home, { recursive: true, force: true }); } +} + +// Self-contained so the eval worker can install the SAME guards using only +// builtins, before importing any payload module. Module hooks do not otherwise +// propagate from the dedicated probe process into Worker threads. +function installGuards(require, standalone) { + const assert = require("node:assert/strict"); + const fs = require("node:fs"); + const path = require("node:path"); + const { fileURLToPath } = require("node:url"); + const { registerHooks, syncBuiltinESMExports } = require("node:module"); + const root = fs.realpathSync(standalone); + const inside = (file) => { + const real = fs.realpathSync(file); + const relative = path.relative(root, real); + assert.ok(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative), + `dependency escaped standalone payload: ${file}`); + return real; + }; + const photonRoot = path.join(root, "node_modules/@silvia-odwyer/photon-node"); + const manifest = JSON.parse(fs.readFileSync(inside(path.join(photonRoot, "package.json")), "utf8")); + assert.equal(manifest.version, "0.3.4", "unexpected Photon engine version"); + const photonEntry = inside(path.join(photonRoot, "photon_rs.js")); + const photonWasm = inside(path.join(photonRoot, "photon_rs_bg.wasm")); + assert.ok(fs.statSync(photonWasm).size > 0, "empty Photon WASM asset"); + let attempts = 0; + const deny = () => { attempts++; throw new Error("OFFLINE image gate: network forbidden"); }; + globalThis.fetch = deny; + globalThis.WebSocket = class { constructor() { deny(); } }; + for (const [name, methods] of [ + ["http", ["request", "get"]], ["https", ["request", "get"]], + ["net", ["connect", "createConnection"]], ["tls", ["connect"]], + ["dgram", ["createSocket"]], + ["dns", ["lookup", "resolve", "resolve4", "resolve6", "resolveAny", "reverse"]], + ["dns/promises", ["lookup", "resolve", "resolve4", "resolve6", "resolveAny", "reverse"]], + ]) { + const module = require(`node:${name}`); + for (const method of methods) module[method] = deny; + } + require("node:net").Socket.prototype.connect = deny; + const loaded = new Set(); + const wasmReads = new Set(); + const originalRead = fs.readFileSync; + fs.readFileSync = function (file, ...args) { + const filename = file instanceof URL ? fileURLToPath(file) : file; + const wasmFile = typeof filename === "string" && filename.endsWith(".wasm") ? inside(filename) : null; + const bytes = originalRead.call(this, file, ...args); + if (wasmFile) wasmReads.add(wasmFile); + return bytes; + }; + syncBuiltinESMExports(); + const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + const result = nextResolve(specifier, context); + if (result.url.startsWith("file:")) loaded.add(inside(fileURLToPath(result.url))); + else assert.ok(result.url.startsWith("node:"), `unexpected dependency URL: ${result.url}`); + return result; + }, + }); + return { + report() { + assert.equal(attempts, 0, "image runtime attempted network access"); + assert.ok(loaded.has(photonEntry), "Photon JS was not loaded from the payload"); + assert.ok(wasmReads.has(photonWasm), "Photon WASM was not loaded from the payload"); + return { modules: loaded.size, wasmFiles: [...wasmReads], networkAttempts: attempts }; + }, + cleanup() { + hooks.deregister(); + fs.readFileSync = originalRead; + syncBuiltinESMExports(); + }, + }; +} + +/** Pure Node 2×2 RGBA PNG, with valid PNG CRCs and zlib image data. */ +function fixturePng() { + const chunk = (type, data) => { + const buffer = Buffer.alloc(data.length + 12); + buffer.writeUInt32BE(data.length); + buffer.write(type, 4, "ascii"); + data.copy(buffer, 8); + let crc = ~0; + for (const byte of buffer.subarray(4, -4)) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + buffer.writeUInt32BE(~crc >>> 0, buffer.length - 4); + return buffer; + }; + const header = Buffer.alloc(13); + header.writeUInt32BE(2, 0); + header.writeUInt32BE(2, 4); + header[8] = 8; // bit depth + header[9] = 6; // RGBA + const pixels = Buffer.from([ + 0, 255, 0, 0, 255, 0, 255, 0, 255, + 0, 0, 0, 255, 255, 255, 255, 255, 255, + ]); + return Buffer.concat([Buffer.from("89504e470d0a1a0a", "hex"), chunk("IHDR", header), chunk("IDAT", deflateSync(pixels)), chunk("IEND", Buffer.alloc(0))]); +} + +function assertResized(result) { + assert.ok(result, "resize returned null (missing/broken Photon must not silently pass)"); + assert.equal(result.originalWidth, 2); + assert.equal(result.originalHeight, 2); + assert.equal(result.width, 1); + assert.equal(result.height, 1); + assert.equal(result.wasResized, true); + assert.equal(result.mimeType, "image/png"); + const png = Buffer.from(result.data, "base64"); + assert.equal(png.subarray(0, 8).toString("hex"), "89504e470d0a1a0a"); + assert.equal(png.readUInt32BE(16), 1); + assert.equal(png.readUInt32BE(20), 1); +} + +async function probeWorker(root, inputBytes, options, responseType) { + const readyType = "lh:image-runtime-ready"; + const worker = new Worker(` + const { parentPort, workerData } = require("node:worker_threads"); + const guards = (${installGuards.toString()})(require, workerData.root); + const post = parentPort.postMessage.bind(parentPort); + parentPort.postMessage = (message, ...args) => { + if (message?.type === workerData.responseType) { + post({ ...message, runtimeProof: guards.report() }, ...args); + } else post(message, ...args); + }; + void import(workerData.entry).then(() => post({ type: workerData.readyType })); + `, { + eval: true, + workerData: { root, responseType, readyType, entry: pathToFileURL(path.join(root, "src/agent/image-processing/resize-worker.mjs")).href }, + }); + try { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("image resize worker timed out")), 10_000); + const fail = (error) => { clearTimeout(timer); reject(error); }; + let ready = false; + worker.once("error", fail); + worker.once("exit", (code) => fail(new Error(`image resize worker exited before response: ${code}`))); + worker.on("message", (message) => { + try { + if (message?.type === readyType) { + assert.equal(ready, false, "duplicate worker READY"); + ready = true; + worker.postMessage({ inputBytes, mimeType: "image/png", options }); + } else if (message?.type === responseType) { + assert.equal(ready, true, "worker replied before its import completed"); + assert.equal(message.error, undefined, `worker resize failed: ${message.error}`); + assertResized(message.result); + assert.ok(message.runtimeProof?.modules > 0, "missing worker module-confinement proof"); + assert.equal(message.runtimeProof.networkAttempts, 0); + assert.equal(message.runtimeProof.wasmFiles.length, 1); + clearTimeout(timer); + resolve(message.runtimeProof); + } + } catch (error) { fail(error); } + }); + }); + } finally { await worker.terminate(); } +} + +async function probe(standalone) { + const root = fs.realpathSync(standalone); + const guards = installGuards(createRequire(import.meta.url), root); + try { + const entry = pathToFileURL(path.join(root, "src/agent/image-processing/resize-core.mjs")); + const { resizeImageInProcess, IMAGE_RESIZE_WORKER_RESPONSE_TYPE } = await import(entry.href); + assert.equal(typeof resizeImageInProcess, "function"); + assert.equal(IMAGE_RESIZE_WORKER_RESPONSE_TYPE, "pi:image-resize-response"); + const input = fixturePng(); + const options = { maxWidth: 1, maxHeight: 1, maxBytes: 1024 * 1024 }; + assertResized(await resizeImageInProcess(input, "image/png", options)); + const inProcess = guards.report(); + console.log(`[smoke-image-runtime] shipped resize core: PNG 2×2 → 1×1; ${inProcess.modules} modules and Photon WASM confined to ${root}`); + const worker = await probeWorker(root, input, options, IMAGE_RESIZE_WORKER_RESPONSE_TYPE); + guards.report(); + console.log(`[smoke-image-runtime] shipped resize worker: READY then tagged PNG 2×2 → 1×1; ${worker.modules} modules and Photon WASM confined to ${root}`); + console.log("[smoke-image-runtime] Photon in-process and worker resizing passed; zero network attempts"); + } finally { guards.cleanup(); } +} + +async function main() { + const args = process.argv.slice(2); + if (args[0] === "--probe") return probe(args[1]); + 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]); + }; + verifyImageRuntime(value("--standalone", path.resolve(import.meta.dirname, "../.next/standalone")), value("--runtime", process.execPath)); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { console.error(`[smoke-image-runtime] FAIL: ${formatProbeError(error)}`); process.exitCode = 1; }); +} diff --git a/src/agent/image-processing/LICENSE b/src/agent/image-processing/LICENSE new file mode 100644 index 0000000..b0a8e9b --- /dev/null +++ b/src/agent/image-processing/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Mario Zechner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/src/agent/image-processing/convert.d.mts b/src/agent/image-processing/convert.d.mts new file mode 100644 index 0000000..1826e6c --- /dev/null +++ b/src/agent/image-processing/convert.d.mts @@ -0,0 +1 @@ +export function convertImageBytesToPng(bytes: Uint8Array): Promise; diff --git a/src/agent/image-processing/convert.mjs b/src/agent/image-processing/convert.mjs new file mode 100644 index 0000000..f271633 --- /dev/null +++ b/src/agent/image-processing/convert.mjs @@ -0,0 +1,25 @@ +// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-convert.js (MIT). +// Only inline-image conversion is retained; terminal/Kitty registration is not used. +import { applyExifOrientation } from "./exif-orientation.mjs"; +import { loadPhoton } from "./photon.mjs"; + +function encodePng(photon, bytes) { + try { + const rawImage = photon.PhotonImage.new_from_byteslice(bytes); + const image = applyExifOrientation(photon, rawImage, bytes); + if (image !== rawImage) rawImage.free(); + try { + return new Uint8Array(image.get_bytes()); + } finally { + image.free(); + } + } catch { + return null; + } +} + +export async function convertImageBytesToPng(bytes) { + const photon = await loadPhoton(); + if (!photon) return null; + return encodePng(photon, bytes); +} diff --git a/src/agent/image-processing/exif-orientation.mjs b/src/agent/image-processing/exif-orientation.mjs new file mode 100644 index 0000000..1915c7f --- /dev/null +++ b/src/agent/image-processing/exif-orientation.mjs @@ -0,0 +1,157 @@ +// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/exif-orientation.js (MIT). +function readOrientationFromTiff(bytes, tiffStart) { + if (tiffStart + 8 > bytes.length) + return 1; + const byteOrder = (bytes[tiffStart] << 8) | bytes[tiffStart + 1]; + const le = byteOrder === 0x4949; + const read16 = (pos) => { + if (le) + return bytes[pos] | (bytes[pos + 1] << 8); + return (bytes[pos] << 8) | bytes[pos + 1]; + }; + const read32 = (pos) => { + if (le) + return bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16) | (bytes[pos + 3] << 24); + return ((bytes[pos] << 24) | (bytes[pos + 1] << 16) | (bytes[pos + 2] << 8) | bytes[pos + 3]) >>> 0; + }; + const ifdOffset = read32(tiffStart + 4); + const ifdStart = tiffStart + ifdOffset; + if (ifdStart + 2 > bytes.length) + return 1; + const entryCount = read16(ifdStart); + for (let i = 0; i < entryCount; i++) { + const entryPos = ifdStart + 2 + i * 12; + if (entryPos + 12 > bytes.length) + return 1; + if (read16(entryPos) === 0x0112) { + const value = read16(entryPos + 8); + return value >= 1 && value <= 8 ? value : 1; + } + } + return 1; +} +function findJpegTiffOffset(bytes) { + let offset = 2; + while (offset < bytes.length - 1) { + if (bytes[offset] !== 0xff) + return -1; + const marker = bytes[offset + 1]; + if (marker === 0xff) { + offset++; + continue; + } + if (marker === 0xe1) { + if (offset + 4 >= bytes.length) + return -1; + const segmentStart = offset + 4; + if (segmentStart + 6 > bytes.length) + return -1; + if (hasExifHeader(bytes, segmentStart)) + return segmentStart + 6; + } + if (offset + 4 > bytes.length) + return -1; + const length = (bytes[offset + 2] << 8) | bytes[offset + 3]; + offset += 2 + length; + } + return -1; +} +function findWebpTiffOffset(bytes) { + let offset = 12; + while (offset + 8 <= bytes.length) { + const chunkId = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]); + const chunkSize = bytes[offset + 4] | (bytes[offset + 5] << 8) | (bytes[offset + 6] << 16) | (bytes[offset + 7] << 24); + const dataStart = offset + 8; + if (chunkId === "EXIF") { + if (dataStart + chunkSize > bytes.length) + return -1; + // Some WebP files have "Exif\0\0" prefix before the TIFF header + const tiffStart = chunkSize >= 6 && hasExifHeader(bytes, dataStart) ? dataStart + 6 : dataStart; + return tiffStart; + } + // RIFF chunks are padded to even size + offset = dataStart + chunkSize + (chunkSize % 2); + } + return -1; +} +function hasExifHeader(bytes, offset) { + return (bytes[offset] === 0x45 && + bytes[offset + 1] === 0x78 && + bytes[offset + 2] === 0x69 && + bytes[offset + 3] === 0x66 && + bytes[offset + 4] === 0x00 && + bytes[offset + 5] === 0x00); +} +function getExifOrientation(bytes) { + let tiffOffset = -1; + // JPEG: starts with FF D8 + if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8) { + tiffOffset = findJpegTiffOffset(bytes); + } + // WebP: starts with RIFF....WEBP + else if (bytes.length >= 12 && + bytes[0] === 0x52 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x46 && + bytes[8] === 0x57 && + bytes[9] === 0x45 && + bytes[10] === 0x42 && + bytes[11] === 0x50) { + tiffOffset = findWebpTiffOffset(bytes); + } + if (tiffOffset === -1) + return 1; + return readOrientationFromTiff(bytes, tiffOffset); +} +function rotate90(photon, image, dstIndex) { + const w = image.get_width(); + const h = image.get_height(); + const src = image.get_raw_pixels(); + const dst = new Uint8Array(src.length); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const srcIdx = (y * w + x) * 4; + const dstIdx = dstIndex(x, y, w, h) * 4; + dst[dstIdx] = src[srcIdx]; + dst[dstIdx + 1] = src[srcIdx + 1]; + dst[dstIdx + 2] = src[srcIdx + 2]; + dst[dstIdx + 3] = src[srcIdx + 3]; + } + } + return new photon.PhotonImage(dst, h, w); +} +// Flip orientations mutate in-place. Rotations return a new image (caller must free the old one if different). +export function applyExifOrientation(photon, image, originalBytes) { + const orientation = getExifOrientation(originalBytes); + if (orientation === 1) + return image; + switch (orientation) { + case 2: + photon.fliph(image); + return image; + case 3: + photon.fliph(image); + photon.flipv(image); + return image; + case 4: + photon.flipv(image); + return image; + case 5: { + const rotated = rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y)); + photon.fliph(rotated); + return rotated; + } + case 6: + return rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y)); + case 7: { + const rotated = rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y); + photon.fliph(rotated); + return rotated; + } + case 8: + return rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y); + default: + return image; + } +} diff --git a/src/agent/image-processing/index.ts b/src/agent/image-processing/index.ts new file mode 100644 index 0000000..ddeaf9b --- /dev/null +++ b/src/agent/image-processing/index.ts @@ -0,0 +1,59 @@ +// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-process.js (MIT). +import { convertImageBytesToPng } from "./convert.mjs"; +import { formatDimensionNote, resizeImage } from "./resize.ts"; +import type { ProcessImageOptions, ProcessImageResult } from "./types.ts"; + +export { detectSupportedImageMimeType, detectSupportedImageMimeTypeFromFile } from "./mime.mjs"; +export type { ModelImageResizeOptions, ProcessImageOptions, ProcessImageResult, ResizedImage, SupportedImageMimeType } from "./types.ts"; + +function baseMimeType(mimeType: string): string { + return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase(); +} + +function normalizeSupportedImageMimeType(mimeType: string): string | null { + switch (baseMimeType(mimeType)) { + case "image/png": return "image/png"; + case "image/jpeg": + case "image/jpg": return "image/jpeg"; + case "image/gif": return "image/gif"; + case "image/webp": return "image/webp"; + default: return null; + } +} + +async function normalizeImage(bytes: Uint8Array, mimeType: string): Promise<{ bytes: Uint8Array; mimeType: string; convertedFrom?: string } | null> { + const normalizedMimeType = normalizeSupportedImageMimeType(mimeType); + if (normalizedMimeType) return { bytes, mimeType: normalizedMimeType }; + const pngBytes = await convertImageBytesToPng(bytes); + if (!pngBytes) return null; + return { bytes: pngBytes, mimeType: "image/png", convertedFrom: baseMimeType(mimeType) }; +} + +function conversionHint(from: string | undefined, to: string): string | undefined { + if (!from || from === to) return undefined; + return `[Image converted from ${from} to ${to}.]`; +} + +export async function processImage(bytes: Uint8Array, mimeType: string, options?: ProcessImageOptions): Promise { + const autoResizeImages = options?.autoResizeImages ?? true; + const normalized = await normalizeImage(bytes, mimeType); + if (!normalized) { + return { ok: false, message: "[Image omitted: could not be converted to a supported inline image format.]" }; + } + if (autoResizeImages) { + const resized = await resizeImage(normalized.bytes, normalized.mimeType, options?.resizeOptions); + if (!resized) { + return { ok: false, message: "[Image omitted: could not be resized below the inline image size limit.]" }; + } + const hints: string[] = []; + const convertedHint = conversionHint(normalized.convertedFrom, resized.mimeType); + if (convertedHint) hints.push(convertedHint); + const dimensionNote = formatDimensionNote(resized); + if (dimensionNote) hints.push(dimensionNote); + return { ok: true, data: resized.data, mimeType: resized.mimeType, hints }; + } + const hints: string[] = []; + const convertedHint = conversionHint(normalized.convertedFrom, normalized.mimeType); + if (convertedHint) hints.push(convertedHint); + return { ok: true, data: Buffer.from(normalized.bytes).toString("base64"), mimeType: normalized.mimeType, hints }; +} diff --git a/src/agent/image-processing/mime.d.mts b/src/agent/image-processing/mime.d.mts new file mode 100644 index 0000000..6451c62 --- /dev/null +++ b/src/agent/image-processing/mime.d.mts @@ -0,0 +1,3 @@ +import type { SupportedImageMimeType } from "./types.ts"; +export function detectSupportedImageMimeType(buffer: Uint8Array): SupportedImageMimeType | null; +export function detectSupportedImageMimeTypeFromFile(filePath: string): Promise; diff --git a/src/agent/image-processing/mime.mjs b/src/agent/image-processing/mime.mjs new file mode 100644 index 0000000..c9930d6 --- /dev/null +++ b/src/agent/image-processing/mime.mjs @@ -0,0 +1,110 @@ +// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/mime.js (MIT). +import { open } from "node:fs/promises"; +const IMAGE_TYPE_SNIFF_BYTES = 4100; +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +export function detectSupportedImageMimeType(buffer) { + if (startsWith(buffer, [0xff, 0xd8, 0xff])) { + return buffer[3] === 0xf7 ? null : "image/jpeg"; + } + if (startsWith(buffer, PNG_SIGNATURE)) { + return isPng(buffer) && !isAnimatedPng(buffer) ? "image/png" : null; + } + if (startsWithAscii(buffer, 0, "GIF87a") || startsWithAscii(buffer, 0, "GIF89a")) { + return "image/gif"; + } + if (startsWithAscii(buffer, 0, "RIFF") && startsWithAscii(buffer, 8, "WEBP")) { + return "image/webp"; + } + if (startsWithAscii(buffer, 0, "BM") && isBmp(buffer)) { + return "image/bmp"; + } + return null; +} +export async function detectSupportedImageMimeTypeFromFile(filePath) { + const fileHandle = await open(filePath, "r"); + try { + const buffer = Buffer.alloc(IMAGE_TYPE_SNIFF_BYTES); + const { bytesRead } = await fileHandle.read(buffer, 0, IMAGE_TYPE_SNIFF_BYTES, 0); + return detectSupportedImageMimeType(buffer.subarray(0, bytesRead)); + } + finally { + await fileHandle.close(); + } +} +function isPng(buffer) { + return (buffer.length >= 16 && readUint32BE(buffer, PNG_SIGNATURE.length) === 13 && startsWithAscii(buffer, 12, "IHDR")); +} +function isAnimatedPng(buffer) { + let offset = PNG_SIGNATURE.length; + while (offset + 8 <= buffer.length) { + const chunkLength = readUint32BE(buffer, offset); + const chunkTypeOffset = offset + 4; + if (startsWithAscii(buffer, chunkTypeOffset, "acTL")) + return true; + if (startsWithAscii(buffer, chunkTypeOffset, "IDAT")) + return false; + const nextOffset = offset + 8 + chunkLength + 4; + if (nextOffset <= offset || nextOffset > buffer.length) + return false; + offset = nextOffset; + } + return false; +} +function isBmp(buffer) { + if (buffer.length < 26) + return false; + const declaredFileSize = readUint32LE(buffer, 2); + const pixelDataOffset = readUint32LE(buffer, 10); + const dibHeaderSize = readUint32LE(buffer, 14); + if (declaredFileSize !== 0 && declaredFileSize < 26) + return false; + if (pixelDataOffset < 14 + dibHeaderSize) + return false; + if (declaredFileSize !== 0 && pixelDataOffset >= declaredFileSize) + return false; + let colorPlanes; + let bitsPerPixel; + if (dibHeaderSize === 12) { + colorPlanes = readUint16LE(buffer, 22); + bitsPerPixel = readUint16LE(buffer, 24); + } + else if (dibHeaderSize >= 40 && dibHeaderSize <= 124) { + if (buffer.length < 30) + return false; + colorPlanes = readUint16LE(buffer, 26); + bitsPerPixel = readUint16LE(buffer, 28); + } + else { + return false; + } + return colorPlanes === 1 && [1, 4, 8, 16, 24, 32].includes(bitsPerPixel); +} +function readUint16LE(buffer, offset) { + return (buffer[offset] ?? 0) + ((buffer[offset + 1] ?? 0) << 8); +} +function readUint32BE(buffer, offset) { + return ((buffer[offset] ?? 0) * 0x1000000 + + ((buffer[offset + 1] ?? 0) << 16) + + ((buffer[offset + 2] ?? 0) << 8) + + (buffer[offset + 3] ?? 0)); +} +function readUint32LE(buffer, offset) { + return ((buffer[offset] ?? 0) + + ((buffer[offset + 1] ?? 0) << 8) + + ((buffer[offset + 2] ?? 0) << 16) + + (buffer[offset + 3] ?? 0) * 0x1000000); +} +function startsWith(buffer, bytes) { + if (buffer.length < bytes.length) + return false; + return bytes.every((byte, index) => buffer[index] === byte); +} +function startsWithAscii(buffer, offset, text) { + if (buffer.length < offset + text.length) + return false; + for (let index = 0; index < text.length; index++) { + if (buffer[offset + index] !== text.charCodeAt(index)) + return false; + } + return true; +} diff --git a/src/agent/image-processing/photon.mjs b/src/agent/image-processing/photon.mjs new file mode 100644 index 0000000..713d627 --- /dev/null +++ b/src/agent/image-processing/photon.mjs @@ -0,0 +1,21 @@ +// Adapted from @earendil-works/pi-coding-agent 1.1.0, dist/utils/photon.js (MIT). +// Source-vs-runtime adaptation: this app runs in Node/Electron, not a Bun +// compiled executable. Photon loads its colocated WASM normally; no global fs +// monkeypatch or executable-relative WASM fallback is needed. +let photonModule = null; +let loadPromise = null; + +export async function loadPhoton() { + if (photonModule) return photonModule; + if (loadPromise) return loadPromise; + loadPromise = (async () => { + try { + photonModule = await import("@silvia-odwyer/photon-node"); + return photonModule; + } catch { + photonModule = null; + return photonModule; + } + })(); + return loadPromise; +} diff --git a/src/agent/image-processing/resize-core.d.mts b/src/agent/image-processing/resize-core.d.mts new file mode 100644 index 0000000..8c2a194 --- /dev/null +++ b/src/agent/image-processing/resize-core.d.mts @@ -0,0 +1,10 @@ +import type { ModelImageResizeOptions, ResizedImage } from "./types.ts"; +export const IMAGE_RESIZE_WORKER_RESPONSE_TYPE: "pi:image-resize-response"; +export function resizeImageInProcess(inputBytes: Uint8Array, mimeType: string, options?: ModelImageResizeOptions): Promise; +export type ResizeImageWorkerResponse = { + type: typeof IMAGE_RESIZE_WORKER_RESPONSE_TYPE; + result: ResizedImage | null; +} | { + type: typeof IMAGE_RESIZE_WORKER_RESPONSE_TYPE; + error: string; +}; diff --git a/src/agent/image-processing/resize-core.mjs b/src/agent/image-processing/resize-core.mjs new file mode 100644 index 0000000..3e89f7c --- /dev/null +++ b/src/agent/image-processing/resize-core.mjs @@ -0,0 +1,130 @@ +// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-resize-core.js (MIT). +// Runtime .mjs resources stay unbundled for Next's Node/Electron worker packaging. +import { applyExifOrientation } from "./exif-orientation.mjs"; +import { loadPhoton } from "./photon.mjs"; +/** + * Tag on image resize worker replies. Node can post its own messages on the worker channel + * (for example `{ "watch:require": [...] }` under `node --watch`), so replies must be identifiable. + */ +export const IMAGE_RESIZE_WORKER_RESPONSE_TYPE = "pi:image-resize-response"; +// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit. +const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024; +const DEFAULT_OPTIONS = { + maxWidth: 2000, + maxHeight: 2000, + maxBytes: DEFAULT_MAX_BYTES, + jpegQuality: 80, +}; +function encodeCandidate(buffer, mimeType) { + const data = Buffer.from(buffer).toString("base64"); + return { + data, + encodedSize: Buffer.byteLength(data, "utf-8"), + mimeType, + }; +} +/** + * Resize an image to fit within the specified max dimensions and encoded file size. + * Returns null if the image cannot be resized below maxBytes. + * + * Uses Photon (Rust/WASM) for image processing. If Photon is not available, + * returns null. + * + * Strategy for staying under maxBytes: + * 1. First resize to maxWidth/maxHeight + * 2. Try PNG first, then JPEG qualities in insertion order (first under cap wins) + * 3. If still too large, try the remaining JPEG qualities + * 4. If still too large, progressively reduce dimensions until 1x1 + */ +export async function resizeImageInProcess(inputBytes, mimeType, options) { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const inputBase64Size = Math.ceil(inputBytes.byteLength / 3) * 4; + const photon = await loadPhoton(); + if (!photon) { + return null; + } + let image; + try { + const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes); + image = applyExifOrientation(photon, rawImage, inputBytes); + if (image !== rawImage) + rawImage.free(); + const originalWidth = image.get_width(); + const originalHeight = image.get_height(); + const format = mimeType.split("/")[1] ?? "png"; + // Check if already within all limits (dimensions AND encoded size) + if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) { + return { + data: Buffer.from(inputBytes).toString("base64"), + mimeType: mimeType || `image/${format}`, + originalWidth, + originalHeight, + width: originalWidth, + height: originalHeight, + wasResized: false, + }; + } + // Calculate initial dimensions respecting max limits + let targetWidth = originalWidth; + let targetHeight = originalHeight; + if (targetWidth > opts.maxWidth) { + targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth); + targetWidth = opts.maxWidth; + } + if (targetHeight > opts.maxHeight) { + targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight); + targetHeight = opts.maxHeight; + } + function tryEncodings(width, height, jpegQualities) { + const resized = photon.resize(image, width, height, photon.SamplingFilter.Lanczos3); + try { + const candidates = [encodeCandidate(resized.get_bytes(), "image/png")]; + for (const quality of jpegQualities) { + candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg")); + } + return candidates; + } + finally { + resized.free(); + } + } + const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40])); + let currentWidth = targetWidth; + let currentHeight = targetHeight; + while (true) { + const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps); + for (const candidate of candidates) { + if (candidate.encodedSize < opts.maxBytes) { + return { + data: candidate.data, + mimeType: candidate.mimeType, + originalWidth, + originalHeight, + width: currentWidth, + height: currentHeight, + wasResized: true, + }; + } + } + if (currentWidth === 1 && currentHeight === 1) { + break; + } + const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75)); + const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75)); + if (nextWidth === currentWidth && nextHeight === currentHeight) { + break; + } + currentWidth = nextWidth; + currentHeight = nextHeight; + } + return null; + } + catch { + return null; + } + finally { + if (image) { + image.free(); + } + } +} diff --git a/src/agent/image-processing/resize-worker.mjs b/src/agent/image-processing/resize-worker.mjs new file mode 100644 index 0000000..bbc9e88 --- /dev/null +++ b/src/agent/image-processing/resize-worker.mjs @@ -0,0 +1,32 @@ +// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-resize-worker.js (MIT). +import { parentPort } from "node:worker_threads"; +import { IMAGE_RESIZE_WORKER_RESPONSE_TYPE, resizeImageInProcess, } from "./resize-core.mjs"; +function isResizeImageWorkerRequest(value) { + if (!value || typeof value !== "object") + return false; + const record = value; + return record.inputBytes instanceof Uint8Array && typeof record.mimeType === "string"; +} +const port = parentPort; +if (!port) { + throw new Error("image resize worker requires parentPort"); +} +port.once("message", (message) => { + void (async () => { + try { + if (!isResizeImageWorkerRequest(message)) { + throw new Error("Invalid image resize worker request"); + } + const result = await resizeImageInProcess(message.inputBytes, message.mimeType, message.options); + const response = { type: IMAGE_RESIZE_WORKER_RESPONSE_TYPE, result }; + port.postMessage(response); + } + catch (error) { + const response = { + type: IMAGE_RESIZE_WORKER_RESPONSE_TYPE, + error: error instanceof Error ? error.message : String(error), + }; + port.postMessage(response); + } + })(); +}); diff --git a/src/agent/image-processing/resize.ts b/src/agent/image-processing/resize.ts new file mode 100644 index 0000000..09115c0 --- /dev/null +++ b/src/agent/image-processing/resize.ts @@ -0,0 +1,65 @@ +// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-resize.js (MIT). +import path from "node:path"; +import { Worker } from "node:worker_threads"; +import { + IMAGE_RESIZE_WORKER_RESPONSE_TYPE, + resizeImageInProcess, + type ResizeImageWorkerResponse, +} from "./resize-core.mjs"; +import type { ModelImageResizeOptions, ResizedImage } from "./types.ts"; + +function isResizeImageWorkerResponse(value: unknown): value is ResizeImageWorkerResponse { + return value !== null && typeof value === "object" && "type" in value && value.type === IMAGE_RESIZE_WORKER_RESPONSE_TYPE; +} + +async function resizeImageInWorker(workerPath: string, inputBytes: Uint8Array, mimeType: string, options?: ModelImageResizeOptions): Promise { + const worker = new Worker(workerPath); + try { + // Transfer a worker-owned copy so the caller's bytes remain intact. + const inputBytesForWorker = new Uint8Array(inputBytes); + return await new Promise((resolve, reject) => { + let settled = false; + const settle = (result: ResizedImage | null) => { + if (settled) return; + settled = true; + resolve(result); + }; + const fail = (error: Error) => { + if (settled) return; + settled = true; + reject(error); + }; + worker.on("message", (message: unknown) => { + // Ignore Node's own worker-channel messages (e.g. node --watch). + if (!isResizeImageWorkerResponse(message)) return; + if ("error" in message) fail(new Error(message.error)); + else settle(message.result); + }); + worker.once("error", fail); + worker.once("exit", (code) => { + if (!settled) fail(new Error(`Image resize worker exited with code ${code}`)); + }); + worker.postMessage({ inputBytes: inputBytesForWorker, mimeType, options }, [inputBytesForWorker.buffer]); + }); + } finally { + void worker.terminate().catch(() => undefined); + } +} + +export async function resizeImage(inputBytes: Uint8Array, mimeType: string, options?: ModelImageResizeOptions): Promise { + // Source-vs-runtime adaptation: Next bundles the TS orchestration, but traces + // unbundled .mjs worker/core/EXIF/Photon resources at this cwd-relative path. + // The packaged server's cwd is the standalone root. No Bun path is needed. + const workerPath = path.join(process.cwd(), "src/agent/image-processing/resize-worker.mjs"); + try { + return await resizeImageInWorker(workerPath, inputBytes, mimeType, options); + } catch { + return resizeImageInProcess(inputBytes, mimeType, options); + } +} + +export function formatDimensionNote(result: ResizedImage): string | undefined { + if (!result.wasResized) return undefined; + const scale = result.originalWidth / result.width; + return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`; +} diff --git a/src/agent/image-processing/types.ts b/src/agent/image-processing/types.ts new file mode 100644 index 0000000..34cc117 --- /dev/null +++ b/src/agent/image-processing/types.ts @@ -0,0 +1,35 @@ +// Port baseline: @earendil-works/pi-coding-agent 1.1.0 image utilities (MIT). +/** Native resize overrides, supplied by the caller for the selected model. */ +export interface ModelImageResizeOptions { + maxWidth?: number; + maxHeight?: number; + maxBytes?: number; + jpegQuality?: number; +} + +export interface ResizedImage { + data: string; + mimeType: string; + originalWidth: number; + originalHeight: number; + width: number; + height: number; + wasResized: boolean; +} + +export interface ProcessImageOptions { + autoResizeImages?: boolean; + resizeOptions?: ModelImageResizeOptions; +} + +export type ProcessImageResult = { + ok: true; + data: string; + mimeType: string; + hints: string[]; +} | { + ok: false; + message: string; +}; + +export type SupportedImageMimeType = "image/jpeg" | "image/png" | "image/gif" | "image/webp" | "image/bmp"; diff --git a/src/agent/subagents/headless.ts b/src/agent/subagents/headless.ts index 2325fe9..6277017 100644 --- a/src/agent/subagents/headless.ts +++ b/src/agent/subagents/headless.ts @@ -461,6 +461,54 @@ async function renderSvg(svg: string, opts: RenderSvgOptions = {}): Promise Number.isFinite(n) && n > 0)) { + throw new Error("PDF page dimensions must be finite and positive"); + } + if (!Number.isFinite(dpi) || dpi <= 0 || dpi / 72 === 0) { + throw new Error("PDF render DPI must be finite and positive"); + } + // Limit sides first, so the area calculation cannot overflow even when + // the unscaled page dimensions or requested DPI are enormous. + let scale = Math.min(dpi / 72, MAX_PDF_CANVAS_SIDE / baseWidth, MAX_PDF_CANVAS_SIDE / baseHeight); + const area = (baseWidth * scale) * (baseHeight * scale); + if (area > MAX_PDF_CANVAS_PIXELS) scale *= Math.sqrt(MAX_PDF_CANVAS_PIXELS / area); + const dimensions = (s: number) => ({ + width: Math.max(1, Math.ceil(baseWidth * s)), + height: Math.max(1, Math.ceil(baseHeight * s)), + }); + const fits = ({ width, height }: { width: number; height: number }) => + width <= MAX_PDF_CANVAS_SIDE && height <= MAX_PDF_CANVAS_SIDE && width * height <= MAX_PDF_CANVAS_PIXELS; + // Ceil can put an otherwise bounded floating-point viewport over budget. + // Find a safe scale rather than clipping the viewport or allocating first. + if (!fits(dimensions(scale))) { + let low = 0; + let high = scale; + for (let i = 0; i < 64; i++) { + const mid = low + (high - low) / 2; + if (fits(dimensions(mid))) low = mid; + else high = mid; + } + scale = low; + } + if (scale <= 0 || baseWidth * scale === 0 || baseHeight * scale === 0) { + throw new Error("PDF page dimensions cannot be rendered at a positive scale"); + } + return { scale, ...dimensions(scale) }; +} + /** Render PDF pages to PNGs — the sources-v0 pdf-reader's eyes. * * History: pdfjs ALWAYS painted the pages (scale = dpi/72, the skill's @@ -475,6 +523,9 @@ async function renderPdfPages( opts: RenderPdfOptions = {}, ): Promise { const dpi = opts.dpi ?? 150; + if (!Number.isFinite(dpi) || dpi <= 0 || dpi / 72 === 0) { + throw new Error("PDF render DPI must be finite and positive"); + } // NATIVE CANVAS (Wave E, owner 2026-10-10): pdfjs paints into // @napi-rs/canvas in this process — no browser involved. pdfjs-dist v6's // own Node integration IS @napi-rs/canvas (its optionalDependency: the @@ -495,8 +546,10 @@ async function renderPdfPages( for (const pageNo of pages) { if (pageNo < 1 || pageNo > doc.numPages) continue; const pg = await doc.getPage(pageNo); - const vp = pg.getViewport({ scale: dpi / 72 }); - const canvas = createCanvas(Math.ceil(vp.width), Math.ceil(vp.height)); + const base = pg.getViewport({ scale: 1 }); + const geometry = pdfCanvasGeometry(base.width, base.height, dpi); + const vp = pg.getViewport({ scale: geometry.scale }); + const canvas = createCanvas(geometry.width, geometry.height); const ctx = canvas.getContext("2d"); // pdfjs's render types speak the DOM context; @napi-rs/canvas's // context is the same 2D API implemented over Skia. diff --git a/src/agent/subagents/pdf-reader.ts b/src/agent/subagents/pdf-reader.ts index c3feae2..250c158 100644 --- a/src/agent/subagents/pdf-reader.ts +++ b/src/agent/subagents/pdf-reader.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai"; import { normalizeContext } from "@earendil-works/pi-ai"; +import { processImage, type ProcessImageResult } from "../image-processing/index.ts"; import { Type } from "typebox"; import { resolveSourcePath } from "../tools/fs-shared.ts"; import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts"; @@ -246,7 +247,7 @@ export async function runPdfReaderAgent( const toolResults: ToolResultMessage[] = []; /** Rendered pages this batch, appended as images AFTER the tool * results — the visual-maker's image-in-the-loop idiom. */ - let renderedImages: { page: number; png: Buffer }[] | null = null; + let renderedImages: { page: number; image: ProcessImageResult }[] | null = null; for (const call of toolCalls) { const args = (call.arguments ?? {}) as Record; @@ -287,19 +288,26 @@ export async function runPdfReaderAgent( const wanted = specNumbers(spec, RENDER_BATCH_CAP); opts.onActivity?.(`pdf_render: ${spec} @${dpi}dpi`); const pages = await headless.renderPdfPages(pdfPath, wanted, { dpi }); + const processed: { page: number; image: ProcessImageResult }[] = []; for (const p of pages) { - consulted.add(p.page); - read.add(p.page); + const image = await processImage(p.png, "image/png", { resizeOptions: engine.model.inputLimits?.images?.resize }); + opts.abortSignal?.throwIfAborted(); + processed.push({ page: p.page, image }); + // Only images actually delivered to the model count as read. + if (image.ok) { + consulted.add(p.page); + read.add(p.page); + } } if (pages.length === 0) { toolResults.push(toolText(call.id, call.name, "ERROR: no pages rendered (spec matched nothing?)")); } else { - renderedImages = pages; + renderedImages = [...(renderedImages ?? []), ...processed]; toolResults.push( toolText( call.id, call.name, - `Rendered ${pages.length} page(s) at ${dpi} DPI — the images follow below. Read them carefully (equations, diagrams, layout).`, + `Rendered ${pages.length} page(s) at ${dpi} DPI — processed images or explicit omission notes follow below. Read the images carefully (equations, diagrams, layout).`, ), ); } @@ -323,11 +331,12 @@ export async function runPdfReaderAgent( type: "text", text: `The rendered page(s), in order (${renderedImages.map((p) => `p${p.page}`).join(", ")}):`, }, - ...renderedImages.map((p) => ({ - type: "image" as const, - data: p.png.toString("base64"), - mimeType: "image/png", - })), + ...renderedImages.flatMap(({ page, image }) => image.ok + ? [ + { type: "text" as const, text: [`Page ${page}`, ...image.hints].join("\n") }, + { type: "image" as const, data: image.data, mimeType: image.mimeType }, + ] + : [{ type: "text" as const, text: `Page ${page}: ${image.message}` }]), ], timestamp: Date.now(), }, diff --git a/src/agent/subagents/visual-maker.ts b/src/agent/subagents/visual-maker.ts index 8daac95..d0707c3 100644 --- a/src/agent/subagents/visual-maker.ts +++ b/src/agent/subagents/visual-maker.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import type { AssistantMessage, Message, Tool, ToolResultMessage, Usage } from "@earendil-works/pi-ai"; import { normalizeContext } from "@earendil-works/pi-ai"; +import { processImage, type ProcessImageResult } from "../image-processing/index.ts"; import { Type } from "typebox"; import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts"; import { providerEventObserver, type ProviderEventObserver } from "../provider-events.ts"; @@ -193,6 +194,9 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO const msg = err instanceof Error ? err.message : String(err); return { svg: "", notes: `FAILED: rendering the final SVG failed — ${msg}` }; } + const image = await processImage(png, "image/png", { resizeOptions: engine.model.inputLimits?.images?.resize }); + opts.abortSignal?.throwIfAborted(); + if (!image.ok) return { svg: "", notes: `FAILED: inspecting the final SVG failed — ${image.message}` }; opts.onActivity?.("render_svg: inspecting final render"); renderedSvg = currentSvg; messages = [ @@ -200,8 +204,8 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO { role: "user", content: [ - { type: "text", text: UNLOOKED_FINISH_MESSAGE }, - { type: "image", data: png.toString("base64"), mimeType: "image/png" }, + { type: "text", text: [UNLOOKED_FINISH_MESSAGE, ...image.hints].join("\n") }, + { type: "image", data: image.data, mimeType: image.mimeType }, ], timestamp: Date.now(), }, @@ -212,7 +216,7 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO } const toolResults: ToolResultMessage[] = []; - let renderPng: Buffer | null = null; // appended as the image message AFTER the tool results + let renderImage: Extract | null = null; // appended AFTER the tool results for (const call of toolCalls) { if (call.name === "write_svg") { const svg = (call.arguments as { svg?: unknown }).svg; @@ -260,7 +264,10 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO } try { const png = await headless.renderSvg(currentSvg); - renderPng = png; + const image = await processImage(png, "image/png", { resizeOptions: engine.model.inputLimits?.images?.resize }); + opts.abortSignal?.throwIfAborted(); + if (!image.ok) throw new Error(image.message); + renderImage = image; renderedSvg = currentSvg; opts.onActivity?.("render_svg: inspecting render"); toolResults.push(toolText(call.id, call.name, "Rendered — the PNG follows in the next message. Look at it.")); @@ -275,14 +282,14 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO } // Message order: assistant tool-calls → tool results → render image. messages = [...messages, ...toolResults]; - if (renderPng) { + if (renderImage) { messages = [ ...messages, { role: "user", content: [ - { type: "text", text: LOOK_MESSAGE }, - { type: "image", data: renderPng.toString("base64"), mimeType: "image/png" }, + { type: "text", text: [LOOK_MESSAGE, ...renderImage.hints].join("\n") }, + { type: "image", data: renderImage.data, mimeType: renderImage.mimeType }, ], timestamp: Date.now(), }, diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts index 6fe6ac1..f2178c8 100644 --- a/src/agent/teacher-agent.ts +++ b/src/agent/teacher-agent.ts @@ -17,6 +17,7 @@ import { import { practiceTools, type PracticeToolContext } from "./tools/practice.ts"; import { healToolRecord, withArgumentHeal } from "./tools/heal.ts"; import { sendToUserTool } from "./tools/send-to-user.ts"; +import { createReadTool, readTool } from "./tools/read.ts"; import { quizInputObject, quizInputProblems } from "../core/question-core.ts"; import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts"; import { toLlmMessages, nativeInputCause, PRACTICE_PROBLEM_TOOL } from "../core/native-messages.ts"; @@ -201,7 +202,9 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent // Match Claude across providers (including OpenRouter), not the API // transport. Explicit test registries remain caller-owned. tools: [ - ...tools, + ...tools.map((tool) => tool.name === "read" && tool.execute === readTool.execute + ? { ...tool, execute: createReadTool(engine.model).execute as AgentTool["execute"] } + : tool), ...(opts.tools === undefined && engine.model.id.toLowerCase().includes("claude") ? [withArgumentHeal(sendToUserTool)] : []), diff --git a/src/agent/tools/read.ts b/src/agent/tools/read.ts index 7550d70..c7a6300 100644 --- a/src/agent/tools/read.ts +++ b/src/agent/tools/read.ts @@ -1,9 +1,10 @@ import * as fs from "node:fs/promises"; -import * as path from "node:path"; import { Type } from "typebox"; +import type { Api, Model } from "@earendil-works/pi-ai"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import { serverExecute, type ImageFileResult } from "../tool-execute.ts"; import { resolveSourcePath } from "./fs-shared.ts"; +import { detectSupportedImageMimeTypeFromFile, processImage } from "../image-processing/index.ts"; /** * The read tool — sources v0 (2026-09): a Node port of pi's read semantics @@ -31,17 +32,6 @@ import { resolveSourcePath } from "./fs-shared.ts"; const MAX_LINES = 2000; const MAX_BYTES = 50 * 1024; -/** Extension map for v0 (pi sniffs magic bytes; extensions suffice here — - * learner files are pasted paths, not adversarial uploads). */ -const IMAGE_MIME: Record = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", - ".bmp": "image/bmp", -}; - const readInput = Type.Object({ path: Type.String({ minLength: 1, description: "Path to the file to read (relative or absolute)" }), offset: Type.Optional(Type.Integer({ minimum: 1, description: "Line number to start reading from (1-indexed)" })), @@ -95,26 +85,39 @@ function readTextSlice(text: string, offset: number | undefined, limit: number | * fragment (prompts/shared/). Relative paths resolve against the * learner's home (fs-shared.ts), the harness's anchor. */ -export const readTool: AgentTool = { - name: "read", - label: "read", - description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50.0KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`, - parameters: readInput, - execute: serverExecute("read", readInput, async ({ path: rawPath, offset, limit }): Promise => { - const absolute = resolveSourcePath(rawPath); - const ext = path.extname(absolute).toLowerCase(); - const mime = IMAGE_MIME[ext]; - const buffer = await fs.readFile(absolute); // ENOENT/EACCES → error result the model sees - if (mime) { - // NOTE v0: no resize — raw bytes as base64. Learner files are study - // material (screenshots, figures), not adversarial payloads; a resize - // step (sharp) can land later if large images prove wasteful. - return { - text: `Read image file [${mime}] — ${path.basename(absolute)} (${formatSize(buffer.byteLength)}), attached as an image.`, - images: [{ data: buffer.toString("base64"), mimeType: mime }], - }; - } - const text = buffer.toString("utf-8"); - return readTextSlice(text, offset, limit); - }), -}; +export type ReadModel = Pick, "input" | "inputLimits">; + +/** One implementation for every learner-file reader. Bind the active model + * per agent, rather than sharing mutable model state between conversations. + * Home-relative paths, text slicing and the app's abort guard stay unchanged; + * image detection/processing and its notes follow native Pi read behavior. */ +export function createReadTool(model?: ReadModel): AgentTool { + return { + name: "read", + label: "read", + description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50.0KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`, + parameters: readInput, + execute: serverExecute("read", readInput, async ({ path: rawPath, offset, limit }, { signal }): Promise => { + const absolute = resolveSourcePath(rawPath); + const mime = await detectSupportedImageMimeTypeFromFile(absolute); + const buffer = await fs.readFile(absolute, { signal }); + if (mime) { + const processed = await processImage(buffer, mime, { resizeOptions: model?.inputLimits?.images?.resize }); + signal?.throwIfAborted(); + const note = processed.ok + ? [`Read image file [${processed.mimeType}]`, ...processed.hints] + : [`Read image file [${mime}]`, processed.message]; + if (model && !model.input.includes("image")) { + note.push("[Current model does not support images. The image will be omitted from this request.]"); + } + return { + text: note.join("\n"), + ...(processed.ok ? { images: [{ data: processed.data, mimeType: processed.mimeType }] } : {}), + }; + } + return readTextSlice(buffer.toString("utf-8"), offset, limit); + }), + }; +} + +export const readTool = createReadTool(); diff --git a/tests/agent-image-paths.test.ts b/tests/agent-image-paths.test.ts new file mode 100644 index 0000000..7c5eb62 --- /dev/null +++ b/tests/agent-image-paths.test.ts @@ -0,0 +1,351 @@ +/** Offline integration contracts at the provider boundary: real shared image + * processing, faux model streams, synthetic rasters, and fake headless I/O. + * No real browser, provider, learner data, or ambient credentials are used. */ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; +import { createCanvas } from "@napi-rs/canvas"; +import * as photon from "@silvia-odwyer/photon-node"; +import { + createFauxCore, + fauxAssistantMessage, + fauxText, + fauxToolCall, + type FauxResponseStep, + type ImageContent, + type TranscriptContext, +} from "@earendil-works/pi-ai"; +import type { PiEngine } from "../src/agent/models.ts"; +import type { ModelImageResizeOptions } from "../src/agent/image-processing/index.ts"; +import type { HeadlessApi } from "../src/agent/subagents/headless.ts"; + +// Install storage/auth seams BEFORE importing the agent composition root. +const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-agent-image-paths-")); +const originalEnv = process.env; +const originalFetch = globalThis.fetch; +fs.mkdirSync(path.join(scratch, "home")); +fs.writeFileSync(path.join(scratch, "auth.json"), "{}"); +process.env = { + ...(originalEnv.NODE_TEST_CONTEXT ? { NODE_TEST_CONTEXT: originalEnv.NODE_TEST_CONTEXT } : {}), + ...(originalEnv.NODE_OPTIONS ? { NODE_OPTIONS: originalEnv.NODE_OPTIONS } : {}), + NODE_ENV: "test", + HOME: path.join(scratch, "home"), + LH_DATA_DIR: path.join(scratch, "data"), + LH_AUTH_FILE: path.join(scratch, "auth.json"), +}; +globalThis.fetch = async () => { throw new Error("CLOSED: unexpected network request"); }; +after(() => { + process.env = originalEnv; + globalThis.fetch = originalFetch; + fs.rmSync(scratch, { recursive: true, force: true }); +}); + +const { readTool } = await import("../src/agent/tools/read.ts"); +const { createTeacherAgent, createProberAgent, createPracticeAgent } = await import("../src/agent/teacher-agent.ts"); +const { runPdfReaderAgent } = await import("../src/agent/subagents/pdf-reader.ts"); +const { runVisualMaker } = await import("../src/agent/subagents/visual-maker.ts"); + +const PDF = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf"); +const SVG = ''; +const CUSTOM_RESIZE = { maxWidth: 320, maxHeight: 200 }; + +function png(width: number, height: number): Buffer { + const canvas = createCanvas(width, height); + const context = canvas.getContext("2d"); + context.fillStyle = "#336699"; + context.fillRect(0, 0, width, height); + return canvas.toBuffer("image/png"); +} + +function dimensions(image: ImageContent): { width: number; height: number } { + const decoded = photon.PhotonImage.new_from_byteslice(Buffer.from(image.data, "base64")); + try { + return { width: decoded.get_width(), height: decoded.get_height() }; + } finally { decoded.free(); } +} + +function images(context: TranscriptContext): ImageContent[] { + return context.messages.flatMap((message) => Array.isArray(message.content) + ? message.content.filter((part): part is ImageContent => part.type === "image") + : []); +} + +function text(context: TranscriptContext): string { + return context.messages.map((message) => typeof message.content === "string" + ? message.content + : message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n")).join("\n"); +} + +function scripted(steps: FauxResponseStep[], resize?: ModelImageResizeOptions, vision = true) { + const faux = createFauxCore({ tokenSize: { min: 64, max: 64 } }); + faux.setResponses(steps); + const base = faux.getModel(); + const model: PiEngine["model"] = { + ...base, + input: vision ? ["text", "image"] : ["text"], + ...(resize ? { inputLimits: { ...base.inputLimits, images: { ...base.inputLimits?.images, resize } } } : {}), + }; + const requests: TranscriptContext[] = []; + const engine: PiEngine = { + model, + streamFn: (model, context, options) => { + // Snapshot the actual provider-facing context, not mutable agent state. + requests.push(structuredClone(context)); + return faux.streamSimple(model, context, options); + }, + }; + return { engine, requests }; +} + +function readSteps(file: string): FauxResponseStep[] { + return [ + fauxAssistantMessage([fauxToolCall("read", { path: file }, { id: "read-1" })], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxText("Inspected the supplied source.")]), + ]; +} + +function fakeHeadless(rendered: Buffer | ((page: number) => Buffer)) { + const svgCalls: string[] = []; + const pdfCalls: number[][] = []; + const headless: HeadlessApi = { + available: () => true, + unavailableReason: () => null, + runDemo: async () => { throw new Error("unexpected browser execution"); }, + renderSvg: async (svg) => { + svgCalls.push(svg); + return typeof rendered === "function" ? rendered(1) : rendered; + }, + renderPdfPages: async (_file, pages) => { + pdfCalls.push([...pages]); + return pages.map((page) => ({ page, png: typeof rendered === "function" ? rendered(page) : rendered })); + }, + }; + return { headless, svgCalls, pdfCalls }; +} + +function assertProcessedImage(context: TranscriptContext, expected: { width: number; height: number }, raw: Buffer): void { + const parts = images(context); + assert.equal(parts.length, 1, "exactly one successful image reaches the faux provider"); + assert.deepEqual(dimensions(parts[0]!), expected); + assert.ok(Buffer.byteLength(parts[0]!.data, "utf8") < 4.5 * 1024 * 1024); + assert.notEqual(parts[0]!.data, raw.toString("base64"), "the oversized raw raster is never sent"); + assert.match(text(context), /\[Image: original \d+x\d+, displayed at \d+x\d+\. Multiply coordinates by/); +} + +test("shared teacher read detects valid image content under a non-image extension", async () => { + const bytes = png(24, 16); + const file = path.join(scratch, "lecture-notes.txt"); + fs.writeFileSync(file, bytes); + const { engine, requests } = scripted(readSteps(file)); + // Pass the actual shared read tool, exercising the teacher's native clone + // of read.execute rather than a pre-bound test-only implementation. + const agent = createTeacherAgent(undefined, { engine, tools: { read: readTool } }); + await agent.prompt("Inspect the supplied local file."); + assert.equal(requests.length, 2); + const visible = requests[1]!; + assert.equal(images(visible).length, 1); + assert.equal(images(visible)[0]!.mimeType, "image/png"); + assert.equal(images(visible)[0]!.data, bytes.toString("base64")); + assert.deepEqual(dimensions(images(visible)[0]!), { width: 24, height: 16 }); + assert.match(text(visible), /Read image file \[image\/png\]/); +}); + +test("shared read omits corrupt recognized images without exposing a raw image fallback", async () => { + // Valid signature/IHDR, missing pixel data: sniff succeeds, decode fails. + const bytes = png(24, 16).subarray(0, 33); + const file = path.join(scratch, "corrupt.png"); + fs.writeFileSync(file, bytes); + const { engine, requests } = scripted(readSteps(file)); + await createTeacherAgent(undefined, { engine, tools: { read: readTool } }).prompt("Inspect the file."); + assert.equal(requests.length, 2); + assert.deepEqual(images(requests[1]!), []); + assert.match(text(requests[1]!), /Image omitted: could not be resized below the inline image size limit/); + assert.ok(!JSON.stringify(requests[1]).includes(bytes.toString("base64"))); +}); + +test("shared read supplies the native nonvision-model note", async () => { + const file = path.join(scratch, "nonvision.png"); + fs.writeFileSync(file, png(24, 16)); + const { engine, requests } = scripted(readSteps(file), undefined, false); + await createTeacherAgent(undefined, { engine, tools: { read: readTool } }).prompt("Inspect the file."); + assert.equal(requests.length, 2); + assert.match(text(requests[1]!), /\[Current model does not support images\. The image will be omitted from this request\.\]/); +}); + +test("teacher read binds independent active-model resize limits without mutating the shared tool", async () => { + const bytes = png(640, 320); + const file = path.join(scratch, "model-bound.png"); + fs.writeFileSync(file, bytes); + const originalExecute = readTool.execute; + const a = scripted(readSteps(file), { maxWidth: 96, maxHeight: 64 }); + const b = scripted(readSteps(file), { maxWidth: 80, maxHeight: 30 }); + const agentA = createTeacherAgent(undefined, { engine: a.engine, tools: { read: readTool } }); + const agentB = createTeacherAgent(undefined, { engine: b.engine, tools: { read: readTool } }); + await agentB.prompt("Inspect with model B."); + await agentA.prompt("Inspect with model A."); + assertProcessedImage(a.requests[1]!, { width: 96, height: 48 }, bytes); + assertProcessedImage(b.requests[1]!, { width: 60, height: 30 }, bytes); + assert.equal(readTool.execute, originalExecute); +}); + +for (const [role, resize, expected] of [ + ["prober", { maxWidth: 96, maxHeight: 64 }, { width: 96, height: 48 }], + ["practice", { maxWidth: 80, maxHeight: 30 }, { width: 60, height: 30 }], +] as const) { + test(`${role}'s actual registry retains argument healing and text reads while binding image limits`, async () => { + const bytes = png(640, 320); + const imageFile = path.join(scratch, `${role}-actual-registry.png`); + const textFile = path.join(scratch, `${role}-actual-registry.txt`); + fs.writeFileSync(imageFile, bytes); + fs.writeFileSync(textFile, "first\nsecond\nthird\nfourth\nfifth"); + const { engine, requests } = scripted([ + fauxAssistantMessage([fauxToolCall("read", { path: imageFile }, { id: "registry-image" })], { stopReason: "toolUse" }), + // Exact existing heal contract: schema-integer fields encoded as + // JSON number strings heal before validation. The path stays intact. + fauxAssistantMessage([fauxToolCall("read", { path: textFile, offset: "2", limit: "2" }, { id: "registry-text" })], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxText("Inspected the image and selected text lines.")]), + ], resize); + const originalExecute = readTool.execute; + const forbidden = () => { throw new Error("unexpected job dispatch or handoff"); }; + const chatStore = { getChat: () => null }; + // Only context ports are mocked; no app/store imports or explicit + // tool overrides. Job/handoff operations must never execute here. + const agent = role === "prober" + ? createProberAgent({ + chatId: "offline-read-prober", chatStore, + jobStore: new Proxy({}, { get: forbidden }), + spawn: forbidden, deliver: forbidden, + } as never, { engine }) + : createPracticeAgent({ chatId: "offline-read-practice", chatStore }, { engine }); + const names = agent.state.tools.map((tool) => tool.name); + assert.ok(names.includes(role === "prober" ? "probe_done" : "problem"), "real role registry was assembled"); + const boundRead = agent.state.tools.find((tool) => tool.name === "read"); + assert.ok(boundRead?.prepareArguments, "model binding preserves the registry's prepareArguments wrapper"); + assert.notEqual(boundRead.execute, originalExecute, "shared read execute is rebound to the active model"); + assert.deepEqual(boundRead.prepareArguments({ path: textFile, offset: "2", limit: "2" }), { path: textFile, offset: 2, limit: 2 }); + await agent.prompt("Inspect the local image, then read the selected text lines."); + assert.equal(requests.length, 3); + assertProcessedImage(requests[1]!, expected, bytes); + const selected = requests[2]!.messages.find((message) => message.role === "toolResult" && message.toolCallId === "registry-text"); + assert.ok(selected && selected.role === "toolResult"); + assert.equal(selected.isError, false); + assert.deepEqual(selected.content, [{ type: "text", text: "second\nthird\n\n[2 more lines in file. Use offset=4 to continue.]" }]); + assert.equal(readTool.execute, originalExecute, "role construction did not mutate the shared tool"); + }); +} + +test("PDF reader preserves both processed image outputs from multiple render calls in one batch", async () => { + const wide = png(4800, 120); + const tall = png(120, 4800); + const { headless, pdfCalls } = fakeHeadless((page) => page === 1 ? wide : tall); + const { engine, requests } = scripted([ + fauxAssistantMessage([ + fauxToolCall("pdf_render", { pages: "1", dpi: 150 }, { id: "batch-render-1" }), + fauxToolCall("pdf_render", { pages: "2", dpi: 150 }, { id: "batch-render-2" }), + ], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxText("Both rendered pages are visible (p. 1, p. 2). Pages consulted: 1, 2")]), + ]); + const result = await runPdfReaderAgent({ path: PDF, question: "Compare the two figures." }, { engine, headless }); + assert.equal(requests.length, 2); + assert.deepEqual(pdfCalls, [[1], [2]]); + const visible = requests[1]!; + const parts = images(visible); + assert.equal(parts.length, 2, "the second render call must not overwrite the first call's images"); + assert.deepEqual(parts.map(dimensions), [{ width: 2000, height: 50 }, { width: 50, height: 2000 }]); + for (let i = 0; i < parts.length; i++) { + assert.ok(parts[i]!.data.length < 4.5 * 1024 * 1024); + assert.notEqual(parts[i]!.data, [wide, tall][i]!.toString("base64")); + } + assert.deepEqual(visible.messages.slice(-3).map((message) => message.role), ["toolResult", "toolResult", "user"]); + const pageMessage = visible.messages.at(-1)!; + assert.ok(Array.isArray(pageMessage.content)); + assert.deepEqual(pageMessage.content.filter((part) => part.type === "text").map((part) => part.text.split("\n")[0]), [ + "The rendered page(s), in order (p1, p2):", "Page 1", "Page 2", + ]); + assert.deepEqual(result.pagesRead, [1, 2]); + assert.deepEqual(result.pagesConsulted, [1, 2]); +}); + +for (const [label, resize, expected] of [ + ["native defaults", undefined, { width: 2000, height: 50 }], + ["active-model overrides", CUSTOM_RESIZE, { width: 320, height: 8 }], +] as const) { + test(`PDF reader sends processed renders using ${label}; failed page is not counted`, async () => { + const bytes = png(4800, 120); + const { headless, pdfCalls } = fakeHeadless((page) => page === 1 ? bytes : Buffer.from("invalid rendered page")); + const { engine, requests } = scripted([ + fauxAssistantMessage([fauxToolCall("pdf_render", { pages: "1-2", dpi: 150 }, { id: "render-1" })], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxText("The visible figure is on page 1 (p. 1). Pages consulted: 1")]), + ], resize); + const result = await runPdfReaderAgent({ path: PDF, question: "Inspect the page figure." }, { engine, headless }); + assert.equal(requests.length, 2); + assert.deepEqual(pdfCalls, [[1, 2]]); + assertProcessedImage(requests[1]!, expected, bytes); + assert.match(text(requests[1]!), /Page 2: \[Image omitted:/); + assert.deepEqual(result.pagesRead, [1]); + assert.deepEqual(result.pagesConsulted, [1]); + assert.match(result.brief, /visible figure/); + const visible = requests[1]!.messages; + assert.equal(visible.at(-2)!.role, "toolResult"); + assert.equal(visible.at(-1)!.role, "user", "processed page images ride the user message after tool results"); + }); + + for (const forced of [false, true]) { + test(`visual maker ${forced ? "forced-final" : "explicit render_svg"} path processes images using ${label}`, async () => { + const bytes = png(4800, 120); + const { headless, svgCalls } = fakeHeadless(bytes); + const steps: FauxResponseStep[] = forced ? [ + fauxAssistantMessage([fauxToolCall("write_svg", { svg: SVG }, { id: "write-1" })], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxText("Premature notes without inspection.")]), + fauxAssistantMessage([fauxText("Confirmed after inspecting the final render.")]), + ] : [ + fauxAssistantMessage([ + fauxToolCall("write_svg", { svg: SVG }, { id: "write-1" }), + fauxToolCall("render_svg", {}, { id: "render-1" }), + ], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxText("Confirmed after inspecting the render.")]), + ]; + const { engine, requests } = scripted(steps, resize); + const result = await runVisualMaker({ brief: "One precise synthetic figure." }, { engine, headless }); + assert.equal(requests.length, forced ? 3 : 2); + assert.deepEqual(svgCalls, [SVG]); + assertProcessedImage(requests.at(-1)!, expected, bytes); + assert.equal(result.svg, SVG); + assert.match(result.notes!, /^Confirmed after inspecting/); + assert.equal(requests.at(-1)!.messages.at(-1)!.role, "user"); + if (forced) { + assert.deepEqual(images(requests[1]!), [], "premature finish did not see an image yet"); + assert.match(text(requests[2]!), /You finished without looking at a render of your final SVG/); + } else { + assert.match(text(requests[1]!), /Here is the current render of your SVG/); + assert.equal(requests[1]!.messages.at(-2)!.role, "toolResult"); + } + }); + } +} + +for (const forced of [false, true]) { + test(`visual maker ${forced ? "forced-final" : "explicit render_svg"} processing failure never verifies or delivers the SVG`, async () => { + const { headless, svgCalls } = fakeHeadless(Buffer.from("corrupt render")); + const steps: FauxResponseStep[] = [ + fauxAssistantMessage([ + fauxToolCall("write_svg", { svg: SVG }, { id: "write-1" }), + ...(!forced ? [fauxToolCall("render_svg", {}, { id: "render-1" })] : []), + ], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxText("Unverified claim that the picture is correct.")]), + ]; + const { engine, requests } = scripted(steps); + const activity: string[] = []; + const result = await runVisualMaker({ brief: "One synthetic figure." }, { engine, headless, onActivity: (line) => activity.push(line) }); + assert.equal(requests.length, 2); + assert.ok(requests.every((request) => images(request).length === 0)); + assert.equal(result.svg, ""); + assert.match(result.notes!, /^FAILED: inspecting the final SVG failed/); + assert.match(result.notes!, /Image omitted:/); + assert.ok(!activity.some((line) => line.includes("inspecting")), "a failed image is never marked inspected"); + assert.equal(svgCalls.length, forced ? 1 : 2, "an explicit processing failure leaves the final-SVG gate unsatisfied"); + if (!forced) assert.match(text(requests[1]!), /RENDER ERROR.*Image omitted:/); + }); +} diff --git a/tests/demos.test.tsx b/tests/demos.test.tsx index d3e1665..48b3098 100644 --- a/tests/demos.test.tsx +++ b/tests/demos.test.tsx @@ -28,6 +28,7 @@ import assert from "node:assert/strict"; import * as fs from "node:fs"; import * as path from "node:path"; import { after, describe, it } from "node:test"; +import { createCanvas } from "@napi-rs/canvas"; import { Value } from "typebox/value"; import { createFauxCore, fauxAssistantMessage, fauxToolCall, type FauxResponseStep, type ToolCall } from "@earendil-works/pi-ai"; import { renderToStaticMarkup } from "react-dom/server"; @@ -171,7 +172,11 @@ function fakeHeadless(opts: { if (opts.renderFailures && api.svgRenders.length <= opts.renderFailures) { throw new Error("XML parse error"); } - return Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG magic + // The shared native image processor decodes renders, so a signature + // alone is no longer a successful fake raster. + const canvas = createCanvas(1, 1); + canvas.getContext("2d").fillRect(0, 0, 1, 1); + return canvas.toBuffer("image/png"); }, }; return api as HeadlessApi & { demoRuns: string[]; svgRenders: string[] }; diff --git a/tests/image-processing.test.ts b/tests/image-processing.test.ts new file mode 100644 index 0000000..3fb0478 --- /dev/null +++ b/tests/image-processing.test.ts @@ -0,0 +1,357 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { Worker } from "node:worker_threads"; +import { createCanvas } from "@napi-rs/canvas"; +import * as photon from "@silvia-odwyer/photon-node"; +import { + detectSupportedImageMimeType, + detectSupportedImageMimeTypeFromFile, + processImage, +} from "../src/agent/image-processing/index.ts"; +import { applyExifOrientation } from "../src/agent/image-processing/exif-orientation.mjs"; +import { resizeImageInProcess } from "../src/agent/image-processing/resize-core.mjs"; +import { resizeImage } from "../src/agent/image-processing/resize.ts"; + +// Synthetic, offline fixtures only. Canvas generates inputs; Photon is the +// actual processing implementation and supplies independent encoding oracles. +function png(width = 96, height = 96, noise = false): Buffer { + const canvas = createCanvas(width, height); + const context = canvas.getContext("2d"); + if (noise) { + const pixels = context.createImageData(width, height); + let state = 0x12345678; + for (let i = 0; i < pixels.data.length; i += 4) { + for (let channel = 0; channel < 3; channel++) { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + pixels.data[i + channel] = state >>> 24; + } + pixels.data[i + 3] = 255; + } + context.putImageData(pixels, 0, 0); + } else { + context.fillStyle = "#33aadd"; + context.fillRect(0, 0, width, height); + } + return canvas.toBuffer("image/png"); +} + +function encodings(bytes: Uint8Array, width: number, height: number, qualities = [80, 85, 70, 55, 40]) { + const image = photon.PhotonImage.new_from_byteslice(bytes); + const resized = photon.resize(image, width, height, photon.SamplingFilter.Lanczos3); + try { + return [ + { mimeType: "image/png", data: Buffer.from(resized.get_bytes()).toString("base64") }, + ...qualities.map((quality) => ({ mimeType: "image/jpeg", data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64") })), + ]; + } finally { + resized.free(); + image.free(); + } +} + +function bmp(): Buffer { + // A 2x1, uncompressed, 24-bit Windows BMP (padded scanline). + const bytes = Buffer.alloc(62); + bytes.write("BM"); + bytes.writeUInt32LE(bytes.length, 2); + bytes.writeUInt32LE(54, 10); + bytes.writeUInt32LE(40, 14); + bytes.writeInt32LE(2, 18); + bytes.writeInt32LE(1, 22); + bytes.writeUInt16LE(1, 26); + bytes.writeUInt16LE(24, 28); + bytes.writeUInt32LE(8, 34); + bytes.set([0, 0, 255, 0, 255, 0], 54); + return bytes; +} + +function exifJpeg(orientation: number): Buffer { + const image = photon.PhotonImage.new_from_byteslice(png(12, 8)); + let jpeg: Buffer; + try { + jpeg = Buffer.from(image.get_bytes_jpeg(80)); + } finally { + image.free(); + } + const exif = Buffer.alloc(36); + exif.set([0xff, 0xe1, 0, 34]); + exif.write("Exif\0\0", 4, "binary"); + exif.write("II", 10); + exif.writeUInt16LE(42, 12); + exif.writeUInt32LE(8, 14); + exif.writeUInt16LE(1, 18); + exif.writeUInt16LE(0x0112, 20); + exif.writeUInt16LE(3, 22); + exif.writeUInt32LE(1, 24); + exif.writeUInt16LE(orientation, 28); + return Buffer.concat([jpeg.subarray(0, 2), exif, jpeg.subarray(2)]); +} + +test("already-fit image bytes are unchanged, MIME aliases normalize, and transfer leaves input intact", async () => { + const bytes = png(); + const original = Buffer.from(bytes); + const result = await processImage(bytes, " Image/PNG; charset=binary "); + assert.deepEqual(result, { ok: true, data: bytes.toString("base64"), mimeType: "image/png", hints: [] }); + assert.deepEqual(bytes, original); + const jpeg = exifJpeg(1); + assert.deepEqual(await processImage(jpeg, "image/jpg"), { ok: true, data: jpeg.toString("base64"), mimeType: "image/jpeg", hints: [] }); +}); + +test("native default dimensions are 2000x2000 and dimension hints use the native mapping", async () => { + const bytes = png(12000, 24); + const result = await processImage(bytes, "image/png"); + assert.ok(result.ok); + assert.equal(result.mimeType, "image/png"); + assert.deepEqual(result.hints, ["[Image: original 12000x24, displayed at 2000x4. Multiply coordinates by 6.00 to map to original image.]"]); + const decoded = photon.PhotonImage.new_from_byteslice(Buffer.from(result.data, "base64")); + try { + assert.equal(decoded.get_width(), 2000); + assert.equal(decoded.get_height(), 4); + } finally { decoded.free(); } + const tall = await resizeImage(png(24, 12000), "image/png"); + assert.ok(tall); + assert.equal(tall.width, 4); + assert.equal(tall.height, 2000); +}); + +test("model dimensions use sequential rounding, not a one-step scale", async () => { + const result = await resizeImage(png(301, 503), "image/png", { maxWidth: 101, maxHeight: 100 }); + assert.ok(result); + // 301x503 -> 101x169 -> 60x100. + assert.equal(result.width, 60); + assert.equal(result.height, 100); + assert.equal(result.data, encodings(png(301, 503), 60, 100)[0]!.data); + const rounding = await resizeImage(png(50, 77), "image/png", { maxWidth: 10, maxHeight: 10 }); + assert.ok(rounding); + assert.equal(rounding.width, 7); // round(100 / round(77 * 10 / 50)); one-step gives 6. + assert.equal(rounding.height, 10); +}); + +test("native cap is strict and measures base64 bytes, not binary bytes", async () => { + const source = png(24, 24); + // A valid PNG with ignored trailing bytes: under 4.5 MiB binary, exactly + // 4.5 MiB base64. At equality the unchanged fast path must NOT run. + const padded = Buffer.alloc((4.5 * 1024 * 1024 / 4) * 3); + source.copy(padded); + assert.ok(padded.length < 4.5 * 1024 * 1024); + assert.equal(padded.toString("base64").length, 4.5 * 1024 * 1024); + const exact = await processImage(padded, "image/png"); + assert.ok(exact.ok); + assert.notEqual(exact.data, padded.toString("base64")); + assert.ok(exact.data.length < 4.5 * 1024 * 1024); + assert.equal(exact.hints.length, 1, "native re-encoding is marked resized even at identical dimensions"); + const justBelow = padded.subarray(0, padded.length - 3); + const below = await processImage(justBelow, "image/png"); + assert.ok(below.ok); + assert.equal(below.data, justBelow.toString("base64")); + assert.deepEqual(below.hints, []); +}); + +test("PNG wins first under cap even when JPEG is smaller", async () => { + const bytes = png(96, 96, true); + const candidates = encodings(bytes, 95, 95); + assert.ok(candidates[0]!.data.length > candidates[1]!.data.length); + const result = await resizeImage(bytes, "image/png", { maxWidth: 95, maxBytes: candidates[0]!.data.length + 1 }); + assert.ok(result); + assert.equal(result.mimeType, "image/png"); + assert.equal(result.data, candidates[0]!.data); +}); + +test("JPEG quality selection is native first-fit [80,85,70,55,40], not smallest output", async () => { + const bytes = png(96, 96, true); + const candidates = encodings(bytes, 96, 96); + assert.ok(candidates[0]!.data.length > candidates[2]!.data.length); + assert.ok(candidates[2]!.data.length > candidates[1]!.data.length); + assert.ok(candidates[1]!.data.length > candidates[3]!.data.length); + const at80 = await resizeImage(bytes, "image/png", { maxBytes: candidates[1]!.data.length + 1 }); + assert.ok(at80); + assert.equal(at80.data, candidates[1]!.data); + // Strict equality excludes 80; 85 is larger, so 70 is the next fit. + const at70 = await resizeImage(bytes, "image/png", { maxBytes: candidates[1]!.data.length }); + assert.ok(at70); + assert.equal(at70.data, candidates[3]!.data); + assert.equal(at70.width, 96); + assert.equal(at70.height, 96); + for (const [index, quality] of [[4, 55], [5, 40]] as const) { + const candidate = candidates[index]!; + const cap = candidate.data.length + 1; + assert.ok(candidates.slice(0, index).every((earlier) => earlier.data.length >= cap)); + const lowerQuality = await resizeImage(bytes, "image/png", { maxBytes: cap }); + assert.ok(lowerQuality); + assert.equal(lowerQuality.data, candidate.data, `native quality ${quality} wins when preceding candidates are over cap`); + assert.equal(lowerQuality.width, 96); + assert.equal(lowerQuality.height, 96); + } + const override = await resizeImage(bytes, "image/png", { jpegQuality: 85, maxBytes: candidates[2]!.data.length + 1 }); + assert.ok(override); + assert.equal(override.data, candidates[2]!.data, "caller quality precedes the native remaining quality steps"); +}); + +test("all encodings over cap reduce each dimension by floor(0.75), eventually omitting at 1x1", async () => { + const bytes = png(97, 95, true); + const full = encodings(bytes, 97, 95); + const next = encodings(bytes, 72, 71); + const maxBytes = Math.min(...full.map((candidate) => candidate.data.length)); + const expected = next.find((candidate) => candidate.data.length < maxBytes); + assert.ok(expected, "synthetic fixture fits on the first reduced dimensions"); + const result = await resizeImage(bytes, "image/png", { maxBytes }); + assert.ok(result); + assert.equal(result.width, 72); + assert.equal(result.height, 71); + assert.equal(result.mimeType, expected.mimeType); + assert.equal(result.data, expected.data); + assert.equal(await resizeImage(bytes, "image/png", { maxBytes: 1 }), null); + const omitted = await processImage(bytes, "image/png", { resizeOptions: { maxBytes: 1 } }); + assert.deepEqual(omitted, { ok: false, message: "[Image omitted: could not be resized below the inline image size limit.]" }); +}); + +test("BMP converts to PNG and reports the native conversion hint", async () => { + const bytes = bmp(); + assert.equal(detectSupportedImageMimeType(bytes), "image/bmp"); + const result = await processImage(bytes, "IMAGE/BMP"); + assert.ok(result.ok); + assert.equal(result.mimeType, "image/png"); + assert.equal(detectSupportedImageMimeType(Buffer.from(result.data, "base64")), "image/png"); + assert.deepEqual(result.hints, ["[Image converted from image/bmp to image/png.]"]); + const decoded = photon.PhotonImage.new_from_byteslice(Buffer.from(result.data, "base64")); + try { + assert.equal(decoded.get_width(), 2); + assert.equal(decoded.get_height(), 1); + } finally { decoded.free(); } +}); + +test("EXIF-oriented dimensions govern resizing, with native unchanged-byte behavior when already fit", async () => { + for (const orientation of [2, 3, 4, 5, 6, 7, 8]) { + const bytes = exifJpeg(orientation); + const untouched = await resizeImage(bytes, "image/jpeg"); + assert.ok(untouched); + assert.equal(untouched.data, bytes.toString("base64")); + assert.equal(untouched.originalWidth, orientation >= 5 ? 8 : 12); + assert.equal(untouched.originalHeight, orientation >= 5 ? 12 : 8); + const resized = await resizeImage(bytes, "image/jpeg", { maxWidth: 6, maxHeight: 6 }); + assert.ok(resized); + assert.equal(resized.width, orientation >= 5 ? 4 : 6); + assert.equal(resized.height, orientation >= 5 ? 6 : 4); + } +}); + +test("EXIF mirrors and rotations preserve exact pixel placement for JPEG and both WebP EXIF layouts", () => { + const expected = [ + [1, 2, 3, 4, 5, 6], // unchanged + [3, 2, 1, 6, 5, 4], // horizontal mirror + [6, 5, 4, 3, 2, 1], // 180 degrees + [4, 5, 6, 1, 2, 3], // vertical mirror + [1, 4, 2, 5, 3, 6], // transpose + [4, 1, 5, 2, 6, 3], // 90 degrees clockwise + [6, 3, 5, 2, 4, 1], // transverse + [3, 6, 2, 5, 1, 4], // 90 degrees counterclockwise + ]; + for (let orientation = 1; orientation <= 8; orientation++) { + const jpeg = exifJpeg(orientation); + // Only metadata is needed here: decoding is covered by the resize test. + const exif = jpeg.subarray(6, 38); + for (const metadata of [jpeg, ...[exif, exif.subarray(6)].map((data) => { + // Odd-size RIFF chunk before EXIF verifies native even-byte padding. + const webp = Buffer.alloc(12 + 10 + 8 + data.length); + webp.write("RIFF", 0); + webp.writeUInt32LE(webp.length - 8, 4); + webp.write("WEBP", 8); + webp.write("JUNK", 12); + webp.writeUInt32LE(1, 16); + webp.write("EXIF", 22); + webp.writeUInt32LE(data.length, 26); + data.copy(webp, 30); + return webp; + })]) { + const pixels = Uint8Array.from([1, 2, 3, 4, 5, 6].flatMap((value) => [value, 0, 0, 255])); + const raw = new photon.PhotonImage(pixels, 3, 2); + const oriented = applyExifOrientation(photon, raw, metadata); + try { + assert.equal(oriented.get_width(), orientation >= 5 ? 2 : 3); + assert.equal(oriented.get_height(), orientation >= 5 ? 3 : 2); + assert.deepEqual(Array.from(oriented.get_raw_pixels()).filter((_, index) => index % 4 === 0), expected[orientation - 1]); + } finally { + if (oriented !== raw) oriented.free(); + raw.free(); + } + } + } +}); + +test("corrupt supported input is omitted, unsupported conversion fails, and explicit autoResizeImages=false retains native bypass", async () => { + const bytes = Buffer.from("not an image"); + assert.deepEqual(await processImage(bytes, "image/png"), { ok: false, message: "[Image omitted: could not be resized below the inline image size limit.]" }); + assert.deepEqual(await processImage(bytes, "image/bmp"), { ok: false, message: "[Image omitted: could not be converted to a supported inline image format.]" }); + assert.deepEqual(await processImage(bytes, "image/png", { autoResizeImages: false }), { ok: true, data: bytes.toString("base64"), mimeType: "image/png", hints: [] }); + const wide = png(12000, 24); + const bypass = await processImage(wide, "image/png", { autoResizeImages: false }); + assert.ok(bypass.ok); + assert.equal(bypass.data, wide.toString("base64")); + const converted = await processImage(bmp(), "image/bmp", { autoResizeImages: false }); + assert.ok(converted.ok); + assert.equal(converted.mimeType, "image/png"); +}); + +test("MIME sniffing matches native supported signatures and rejects APNG/JPEG-LS/malformed BMP", async () => { + assert.equal(detectSupportedImageMimeType(png()), "image/png"); + assert.equal(detectSupportedImageMimeType(Buffer.from([0xff, 0xd8, 0xff, 0xe0])), "image/jpeg"); + assert.equal(detectSupportedImageMimeType(Buffer.from([0xff, 0xd8, 0xff, 0xf7])), null); + assert.equal(detectSupportedImageMimeType(Buffer.from("GIF89a")), "image/gif"); + assert.equal(detectSupportedImageMimeType(Buffer.from("GIF87a")), "image/gif"); + assert.equal(detectSupportedImageMimeType(Buffer.from("RIFFxxxxWEBP")), "image/webp"); + assert.equal(detectSupportedImageMimeType(Buffer.from("BM")), null); + assert.equal(detectSupportedImageMimeType(Buffer.from("not an image")), null); + const truncatedPng = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + assert.equal(detectSupportedImageMimeType(truncatedPng), null); + // MIME sniffing, like Pi, walks chunk headers without checking CRCs. + const source = png(2, 2); + const acTL = Buffer.alloc(20); + acTL.writeUInt32BE(8, 0); + acTL.write("acTL", 4); + const apng = Buffer.concat([source.subarray(0, 33), acTL, source.subarray(33)]); + assert.equal(detectSupportedImageMimeType(apng), null); + const badBmp = bmp(); + badBmp.writeUInt16LE(2, 26); + assert.equal(detectSupportedImageMimeType(badBmp), null); + const dir = await mkdtemp(path.join(os.tmpdir(), "lh-image-processing-")); + try { + await writeFile(path.join(dir, "renamed.txt"), source); + await writeFile(path.join(dir, "animated.png"), apng); + assert.equal(await detectSupportedImageMimeTypeFromFile(path.join(dir, "renamed.txt")), "image/png"); + assert.equal(await detectSupportedImageMimeTypeFromFile(path.join(dir, "animated.png")), null); + await assert.rejects(detectSupportedImageMimeTypeFromFile(path.join(dir, "missing")), { code: "ENOENT" }); + } finally { await rm(dir, { recursive: true, force: true }); } +}); + +test("unbundled Node worker responds with tagged output and matches the in-process fallback", async () => { + const bytes = png(96, 96, true); + const options = { maxWidth: 64, maxHeight: 64, maxBytes: 4000 }; + const worker = new Worker(path.join(process.cwd(), "src/agent/image-processing/resize-worker.mjs")); + try { + const reply = new Promise<{ type: string; result: unknown }>((resolve, reject) => { + worker.once("message", resolve); + worker.once("error", reject); + }); + worker.postMessage({ inputBytes: new Uint8Array(bytes), mimeType: "image/png", options }); + assert.deepEqual(await reply, { type: "pi:image-resize-response", result: await resizeImageInProcess(bytes, "image/png", options) }); + assert.deepEqual(await resizeImage(bytes, "image/png", options), await resizeImageInProcess(bytes, "image/png", options)); + } finally { await worker.terminate(); } +}); + +test("missing cwd-relative worker falls back in-process without altering the caller bytes", async () => { + const cwd = process.cwd(); + const dir = await mkdtemp(path.join(os.tmpdir(), "lh-image-worker-fallback-")); + const bytes = png(); + const options = { maxWidth: 64, maxHeight: 64 }; + const expected = await resizeImageInProcess(bytes, "image/png", options); + try { + process.chdir(dir); + assert.deepEqual(await resizeImage(bytes, "image/png", options), expected); + } finally { + process.chdir(cwd); + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/pdf-native-render.test.ts b/tests/pdf-native-render.test.ts index 017aa8c..2675dc6 100644 --- a/tests/pdf-native-render.test.ts +++ b/tests/pdf-native-render.test.ts @@ -8,9 +8,10 @@ */ import assert from "node:assert/strict"; 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 { realHeadless } from "../src/agent/subagents/headless.ts"; +import { MAX_PDF_CANVAS_PIXELS, MAX_PDF_CANVAS_SIDE, pdfCanvasGeometry, realHeadless } from "../src/agent/subagents/headless.ts"; import { standardFontsDir } from "../src/agent/subagents/pdf-ops.ts"; const FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf"); @@ -31,6 +32,83 @@ function pngFacts(buf: Buffer): { w: number; h: number; idats: number } { return { w, h, idats }; } +/** Synthetic page only — no personal PDF or external fixture generator. */ +function oversizedPdf(): Buffer { + const content = "0 0 1 rg 0 0 20000 10000 re f\n"; + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 20000 10000] /Rotate 90 /Resources << >> /Contents 4 0 R >>", + `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}endstream`, + ]; + let pdf = "%PDF-1.7\n"; + const offsets = [0]; + for (const [index, object] of objects.entries()) { + offsets.push(Buffer.byteLength(pdf)); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + } + const xref = Buffer.byteLength(pdf); + pdf += `xref\n0 ${offsets.length}\n0000000000 65535 f \n`; + for (const offset of offsets.slice(1)) pdf += `${String(offset).padStart(10, "0")} 00000 n \n`; + pdf += `trailer\n<< /Size ${offsets.length} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; + return Buffer.from(pdf); +} + +describe("PDF pre-allocation canvas geometry", () => { + it("uses decimal 16 megapixels and an 8192-pixel side limit", () => { + assert.equal(MAX_PDF_CANVAS_PIXELS, 16_000_000); + assert.equal(MAX_PDF_CANVAS_SIDE, 8192); + }); + + it("preserves ordinary requested DPI, including fractional ceil dimensions and low DPI", () => { + for (const dpi of [11, 72, 144, 150, 300]) { + const geometry = pdfCanvasGeometry(595.28, 841.89, dpi); + assert.deepEqual(geometry, { + scale: dpi / 72, + width: Math.ceil(595.28 * dpi / 72), + height: Math.ceil(841.89 * dpi / 72), + }); + } + }); + + it("bounds extreme aspect ratios, huge geometry, sides, and rounded pixel area without upscaling", () => { + const cases: readonly (readonly [number, number, number])[] = [ + [1e12, 0.000001, 300], + [0.000001, 1e12, 300], + [1e308, 1e308, Number.MAX_VALUE], + [8192.000001, 10, 72], + [6100, 3900, 72], // ceil at the area-limited scale exceeds 16MP + [4000.000001, 4000.000001, 72], + [4000, 4000, 72], // exactly 16MP stays unchanged + ]; + for (const [width, height, dpi] of cases) { + const result = pdfCanvasGeometry(width, height, dpi); + assert.ok(result.scale > 0 && result.scale <= dpi / 72); + assert.equal(result.width, Math.max(1, Math.ceil(width * result.scale))); + assert.equal(result.height, Math.max(1, Math.ceil(height * result.scale))); + assert.ok(result.width <= MAX_PDF_CANVAS_SIDE && result.height <= MAX_PDF_CANVAS_SIDE); + assert.ok(result.width * result.height <= MAX_PDF_CANVAS_PIXELS); + } + assert.deepEqual(pdfCanvasGeometry(4000, 4000, 72), { scale: 1, width: 4000, height: 4000 }); + }); + + it("uses rotated viewport dimensions symmetrically", () => { + const normal = pdfCanvasGeometry(20000, 10000, 144); + const rotated = pdfCanvasGeometry(10000, 20000, 144); + assert.equal(rotated.scale, normal.scale); + assert.equal(rotated.width, normal.height); + assert.equal(rotated.height, normal.width); + }); + + it("rejects nonfinite or nonpositive dimensions and DPI cleanly", () => { + for (const invalid of [0, -1, NaN, Infinity, -Infinity]) { + assert.throws(() => pdfCanvasGeometry(invalid, 792, 72), /dimensions must be finite and positive/); + assert.throws(() => pdfCanvasGeometry(612, invalid, 72), /dimensions must be finite and positive/); + assert.throws(() => pdfCanvasGeometry(612, 792, invalid), /DPI must be finite and positive/); + } + }); +}); + describe("native pdf_render (pdfjs paints, @napi-rs/canvas hosts)", () => { it("the shared font directory ends in pdfjs's required slash on every OS", () => { const fonts = standardFontsDir(); @@ -63,6 +141,32 @@ describe("native pdf_render (pdfjs paints, @napi-rs/canvas hosts)", () => { assert.ok(Math.abs(fb.h - 2 * fa.h) <= 2, `144dpi height ≈ 2× 72dpi (${fa.h} → ${fb.h})`); }); + it("bounds a synthetic oversized rotated PDF before native canvas allocation", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pdf-canvas-test-")); + try { + const fixture = path.join(dir, "oversized.pdf"); + fs.writeFileSync(fixture, oversizedPdf()); + const [page] = await realHeadless.renderPdfPages(fixture, [1], { dpi: 144 }); + assert.ok(page); + const facts = pngFacts(page.png); + const expected = pdfCanvasGeometry(10000, 20000, 144); + assert.equal(facts.w, expected.width); + assert.equal(facts.h, expected.height); + assert.ok(facts.h > facts.w, "PDF rotation is included before scaling"); + assert.ok(facts.w <= 8192 && facts.h <= 8192); + assert.ok(facts.w * facts.h <= 16_000_000); + assert.equal(facts.idats, 1, "raw renderer still returns canonical PNG"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects invalid DPI before opening the PDF", async () => { + for (const dpi of [0, -1, NaN, Infinity, -Infinity, Number.MIN_VALUE]) { + await assert.rejects(realHeadless.renderPdfPages("unused.pdf", [1], { dpi }), /DPI must be finite and positive/); + } + }); + it("skips out-of-range page numbers silently", async () => { const pages = await realHeadless.renderPdfPages(FIXTURE, [0, 2, 99], { dpi: 72 }); assert.deepEqual( diff --git a/tests/provider-tracing.test.ts b/tests/provider-tracing.test.ts index 0c255e4..f9ad601 100644 --- a/tests/provider-tracing.test.ts +++ b/tests/provider-tracing.test.ts @@ -45,6 +45,9 @@ describe("standalone JavaScript tracing exclusions and native workaround policy" "node_modules/pdfjs-dist/legacy/**/*", "node_modules/pdfjs-dist/standard_fonts/**/*", "node_modules/@napi-rs/canvas-*/**/*", + "src/agent/image-processing/*.mjs", + "src/agent/image-processing/LICENSE", + "node_modules/@silvia-odwyer/photon-node/**/*", "node_modules/@resvg/resvg-js-*/**/*", "node_modules/@earendil-works/pi-ai/dist/providers/data/**/*", "node_modules/@earendil-works/pi-ai/dist/auth/oauth/{load,github-copilot,xai,device-code}.js", diff --git a/tests/read-tool.test.ts b/tests/read-tool.test.ts index 6b000d3..b15d70f 100644 --- a/tests/read-tool.test.ts +++ b/tests/read-tool.test.ts @@ -10,10 +10,12 @@ import assert from "node:assert/strict"; 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 { after, describe, it } from "node:test"; +import { createCanvas } from "@napi-rs/canvas"; import { readTool } from "../src/agent/tools/read.ts"; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "lh-read-test-")); +after(() => fs.rmSync(tmp, { recursive: true, force: true })); function write(name: string, content: string): string { const file = path.join(tmp, name); @@ -100,8 +102,10 @@ describe("read tool — text semantics", () => { describe("read tool — image semantics (native content parts)", () => { it("returns native text + image parts for image files, details null (output-less part)", async () => { const file = path.join(tmp, "dot.png"); - // minimal valid PNG header bytes are enough — detection is by extension - fs.writeFileSync(file, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + // Detection now sniffs bytes and the native processor decodes the image. + const canvas = createCanvas(1, 1); + canvas.getContext("2d").fillRect(0, 0, 1, 1); + fs.writeFileSync(file, canvas.toBuffer("image/png")); const result = await readTool.execute!("test", { path: file }); assert.equal(result.content.length, 2); const parts = result.content as Array<{ type: string; text?: string; data?: string; mimeType?: string }>; From c0de3f968d91c9d7c08fd2481cc3b0dc32a2e37c Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 18:17:50 +0200 Subject: [PATCH 68/87] fix: preserve native Claude CLI authentication provenance --- src/agent/claude-code/provider.ts | 3 ++- src/agent/claude-code/stream.ts | 28 +++++++++++++------------- src/core/auth-env.ts | 24 ++++++++++++++++++++++ src/server/keys.ts | 20 +++---------------- tests/claude-code-stream.test.ts | 33 ++++++++++++++++++++++++++----- tests/server-keys.test.ts | 5 +++++ 6 files changed, 76 insertions(+), 37 deletions(-) create mode 100644 src/core/auth-env.ts diff --git a/src/agent/claude-code/provider.ts b/src/agent/claude-code/provider.ts index c190230..aca199b 100644 --- a/src/agent/claude-code/provider.ts +++ b/src/agent/claude-code/provider.ts @@ -7,7 +7,8 @@ const SHARED_MODEL_FIELDS = { provider: CLAUDE_CODE_PROVIDER, baseUrl: "", input: ["text", "image"], - // Billed to the user's Claude subscription, not per token. + // Placeholder rates, not a billing guarantee: the CLI's own auth/routing + // determines charges. Cost presentation remains a separate product decision. cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, reasoning: true, // The CLI has no thinking-off switch; effort low is the floor. diff --git a/src/agent/claude-code/stream.ts b/src/agent/claude-code/stream.ts index 8c9c89a..4eda283 100644 --- a/src/agent/claude-code/stream.ts +++ b/src/agent/claude-code/stream.ts @@ -23,6 +23,7 @@ import { import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messages"; import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse"; +import { externalCliEnv } from "../../core/auth-env.ts"; import { getClaudeBinary } from "./binary.ts"; import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts"; import { ClaudeCodeError } from "./constants.ts"; @@ -99,7 +100,9 @@ export function toClaudeArgs(input: ClaudeArgsInput): string[] { "--mcp-config", input.mcpConfigFile, "--setting-sources", - "", + "user", + "--settings", + JSON.stringify({ disableAllHooks: true }), "--disable-slash-commands", "--no-session-persistence", "--permission-prompts", @@ -588,16 +591,10 @@ function continueLiveProcess(turn: ClaudeTurn, owner: symbol, sessionId: string return true; } -// Either one takes precedence over the logged-in subscription, so a key saved on the Keys page -// would bill turns to the API account. -const API_CREDENTIAL_ENV_NAMES = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]; - -function toClaudeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const claudeEnv: NodeJS.ProcessEnv = { ...env, MCP_TOOL_TIMEOUT: String(MCP_TOOL_TIMEOUT_MS) }; - - for (const name of API_CREDENTIAL_ENV_NAMES) delete claudeEnv[name]; - - return claudeEnv; +function toClaudeEnv(): NodeJS.ProcessEnv { + // Preserve native CLI auth/routing, including launch-environment credentials. + // Claude Code chooses the account and billing method, not LearnHarness. + return { ...externalCliEnv(), MCP_TOOL_TIMEOUT: String(MCP_TOOL_TIMEOUT_MS) }; } async function startProcess( @@ -608,6 +605,7 @@ async function startProcess( options: SimpleStreamOptions | undefined, owner: symbol, configuration: unknown, + env: NodeJS.ProcessEnv, ): Promise { if (options?.signal?.aborted) { turn.fail(ABORT_MESSAGE, "aborted"); return; } const input = toClaudeInput(context.messages); @@ -639,7 +637,7 @@ async function startProcess( mcpConfigFile: files.mcpConfigFile, allowedTools: bridge.allowedTools, }); - child = runClaude(args, { cwd: files.cwd, env: toClaudeEnv(process.env) }); + child = runClaude(args, { cwd: files.cwd, env }); } catch (error) { await removeTurnResources(bridge, files); throw error; @@ -661,10 +659,12 @@ export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunc const stream = createAssistantMessageEventStream(); const turn = new ClaudeTurn(stream, model); - const configuration = configurationForReuse(model, context, toEffort(options?.reasoning)); + const env = toClaudeEnv(); + // An existing child must not retain stale authentication/routing after env changes. + const configuration = { request: configurationForReuse(model, context, toEffort(options?.reasoning)), env }; if (continueLiveProcess(turn, owner, options?.sessionId, configuration, context.messages, options?.signal)) return stream; - startProcess(runClaude, turn, model, context, options, owner, configuration).catch((error: unknown) => { + startProcess(runClaude, turn, model, context, options, owner, configuration, env).catch((error: unknown) => { console.error("claude CLI turn failed:", error); turn.fail(START_FAILED_MESSAGE, "error"); }); diff --git a/src/core/auth-env.ts b/src/core/auth-env.ts new file mode 100644 index 0000000..f2d8a65 --- /dev/null +++ b/src/core/auth-env.ts @@ -0,0 +1,24 @@ +/** Shared provenance only: no credential-store reads in the agent layer. */ +interface AuthBoot { + /** Names owned by the original launch environment. */ + realEnv: Set; + /** Names reflected into process.env by LearnHarness's key store. */ + fillGap: Set; +} +const BOOT_KEY = Symbol.for("lh.authBoot"); + +export function authEnvRecord(): AuthBoot { + const g = globalThis as Record; + if (!g[BOOT_KEY]) g[BOOT_KEY] = { realEnv: new Set(), fillGap: new Set() }; + return g[BOOT_KEY]; +} + +/** External CLIs own their authentication. Never pass LearnHarness-only injections. */ +export function externalCliEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const result = { ...env }; + const { realEnv, fillGap } = authEnvRecord(); + for (const name of fillGap) { + if (!realEnv.has(name)) delete result[name]; + } + return result; +} diff --git a/src/server/keys.ts b/src/server/keys.ts index 706de52..7f65362 100644 --- a/src/server/keys.ts +++ b/src/server/keys.ts @@ -54,23 +54,9 @@ import { import { authStore } from "../store/auth.ts"; import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts"; -// The once-per-process boot record — globalThis anchor (HMR re-imports the -// module; the snapshot must not re-run and misread fill-gapped vars as real -// env). Same pattern as globalThis.__lhExtractionRuns. -interface AuthBoot { - /** Vars that were in the REAL env at first init — env precedence's - * untouchable set. Fill-gapped vars are everything else we set. */ - realEnv: Set; - /** Vars WE put into process.env (the file's reflection) — deletable. */ - fillGap: Set; -} -const BOOT_KEY = Symbol.for("lh.authBoot"); - -function boot(): AuthBoot { - const g = globalThis as Record; - if (!g[BOOT_KEY]) g[BOOT_KEY] = { realEnv: new Set(), fillGap: new Set() }; - return g[BOOT_KEY]; -} +// Shared HMR-stable provenance lets external CLIs exclude file-only injections +// without importing this store-backed module into the agent layer. +import { authEnvRecord as boot } from "../core/auth-env.ts"; /** Is `id` a WRITABLE key name — curated catalog or model-provider env * var (provider-keys interlude: PUT/DELETE accept both). */ diff --git a/tests/claude-code-stream.test.ts b/tests/claude-code-stream.test.ts index 0a61add..67885c7 100644 --- a/tests/claude-code-stream.test.ts +++ b/tests/claude-code-stream.test.ts @@ -36,6 +36,7 @@ import { parseCliLine } from "../src/agent/claude-code/cli-events.ts"; import { ClaudeCodeError } from "../src/agent/claude-code/constants.ts"; import { LIVE_PROCESS_IDLE_TIMEOUT_MS, liveProcessIdleTimeoutMs } from "../src/agent/claude-code/live-processes.ts"; import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; +import { authEnvRecord } from "../src/core/auth-env.ts"; import { createClaudeCodeStreamFunction, toClaudeArgs, toEffort, type RunClaude } from "../src/agent/claude-code/stream.ts"; import { toStreamJsonLine } from "../src/agent/claude-code/transcript.ts"; @@ -298,7 +299,9 @@ test("toClaudeArgs includes every isolation flag from Global Constraints", () => "--mcp-config", "/private/mcp.json", "--setting-sources", - "", + "user", + "--settings", + '{"disableAllHooks":true}', "--disable-slash-commands", "--no-session-persistence", "--permission-prompts", @@ -582,7 +585,7 @@ test("spawns with the model alias, effort, bridge tools, MCP_TOOL_TIMEOUT and th assert.equal(call.env.CLAUDE_CONFIG_DIR, process.env.CLAUDE_CONFIG_DIR); }); -test("spawns without Anthropic API credentials so turns stay on the subscription", async (t) => { +test("preserves existing CLI environment authentication without a subscription guarantee", async (t) => { const saved = { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN }; t.after(() => { for (const [name, value] of Object.entries(saved)) { @@ -597,8 +600,23 @@ test("spawns without Anthropic API credentials so turns stay on the subscription const { calls } = await runScenario(t, readFixtureLines("text.jsonl")); const call = getOnlyCall(calls); - assert.equal(call.env.ANTHROPIC_API_KEY, undefined); - assert.equal(call.env.ANTHROPIC_AUTH_TOKEN, undefined); + assert.equal(call.env.ANTHROPIC_API_KEY, "sk-ant-test"); + assert.equal(call.env.ANTHROPIC_AUTH_TOKEN, "token-test"); +}); + +test("does not inject credentials reflected only from LearnHarness's key store", async (t) => { + const name = "ANTHROPIC_API_KEY"; + const record = authEnvRecord(); + const saved = process.env[name]; + t.after(() => { + record.fillGap.delete(name); + if (saved === undefined) delete process.env[name]; else process.env[name] = saved; + }); + process.env[name] = "lh-only-key"; + record.fillGap.add(name); + const { calls } = await runScenario(t, readFixtureLines("text.jsonl")); + assert.equal(getOnlyCall(calls).env[name], undefined); + assert.equal(process.env[name], "lh-only-key", "other LearnHarness providers keep their env reflection"); }); test("writes the transcript as one stream-json user line on stdin", async (t) => { @@ -911,13 +929,18 @@ test("a mismatched MCP identity cannot admit a streamed call", async (t) => { await assertCleanedUp(getOnlyCall(calls)); }); -for (const change of ["model", "effort", "system prompt", "tool schema", "tool description", "history"] as const) { +for (const change of ["model", "effort", "system prompt", "tool schema", "tool description", "history", "authentication environment"] as const) { test(`a changed ${change} prevents reuse of a held process`, async (t) => { const read: ToolUse = { id: `toolu_reuse_${change}`, name: "read", arguments: { path: "a" } }; const waiting = createFakeRun(t, [...toToolUseMessage([read]), callTool(read), REPLY]); const fresh = createFakeRun(t, readFixtureLines("text.jsonl")); const stream = createClaudeCodeStreamFunction((args, options) => (waiting.calls.length === 0 ? waiting : fresh).runClaude(args, options)); const message = await runTurn(stream, [HI]); + if (change === "authentication environment") { + const saved = process.env.ANTHROPIC_AUTH_TOKEN; + t.after(() => { if (saved === undefined) delete process.env.ANTHROPIC_AUTH_TOKEN; else process.env.ANTHROPIC_AUTH_TOKEN = saved; }); + process.env.ANTHROPIC_AUTH_TOKEN = "changed-cli-auth"; + } const messages = [change === "history" ? { ...HI, content: "replacement history after compaction" } : HI, message, toToolResult(read, "A")]; const context = normalizeContext({ systemPrompt: change === "system prompt" ? "Changed tutor instructions" : "You are a tutor.", diff --git a/tests/server-keys.test.ts b/tests/server-keys.test.ts index 1ffd108..79e43e0 100644 --- a/tests/server-keys.test.ts +++ b/tests/server-keys.test.ts @@ -17,6 +17,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { after, describe, it } from "node:test"; +import { externalCliEnv } from "../src/core/auth-env.ts"; // THIS PROCESS'S ENV IS THE TEST SUBJECT — strip any inherited catalog vars // (a developer shell may export REAL keys; tests use throwaway values only, @@ -72,10 +73,12 @@ describe("boot fill-gap + precedence", () => { assert.ok(row.shadowedByEnv, "file value differs from the env value"); assert.equal(row.masked, `…${ENV_OWNED.slice(-4)} (${ENV_OWNED.length} chars)`); assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-filefilefile", "file copy untouched"); + assert.equal(externalCliEnv().OPENROUTER_API_KEY, ENV_OWNED, "external CLIs retain launch-env ownership"); }); it("unknown file var: fill-gapped into env, surfaced as a generic row", () => { assert.equal(process.env.FUTURE_PLANS_TOKEN, "opaque-plans-token", "reflected at boot"); + assert.equal(externalCliEnv().FUTURE_PLANS_TOKEN, undefined, "external CLIs exclude unknown file-only injections too"); const row = keyStatuses().find((s) => s.id === "FUTURE_PLANS_TOKEN")!; assert.equal(row.info, null, "no curated form"); assert.equal(row.source, "file"); @@ -103,6 +106,7 @@ describe("saveKey / deleteKey (immediate effect, no restart)", () => { const r = saveKey("DEEPGRAM_API_KEY", value); assert.ok(r.ok); assert.equal(process.env.DEEPGRAM_API_KEY, value, "env updated immediately"); + assert.equal(externalCliEnv().DEEPGRAM_API_KEY, undefined); assert.equal(authStore.authVars().DEEPGRAM_API_KEY, value, "file updated"); assert.equal(r.status.source, "file", "the reflected copy is the store's — the file is its origin of truth"); assert.ok(!r.status.shadowedByEnv); @@ -112,6 +116,7 @@ describe("saveKey / deleteKey (immediate effect, no restart)", () => { const r = saveKey("OPENROUTER_API_KEY", "sk-or-v1-newvalue12345"); assert.ok(r.ok); assert.equal(process.env.OPENROUTER_API_KEY, ENV_OWNED, "real env untouched"); + assert.equal(externalCliEnv().OPENROUTER_API_KEY, ENV_OWNED, "saving a shadowed key cannot remove real env ownership"); assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-newvalue12345", "file updated for later"); assert.ok(r.status.shadowedByEnv); }); From c4e934c9dc9f03b5b22c90d438abdc89573ff60a Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 18:54:57 +0200 Subject: [PATCH 69/87] feat: attach desktop file paths as editable learner chips --- components/answered-card.tsx | 5 +- components/chat.tsx | 13 +- components/composer.tsx | 9 +- components/dictation.tsx | 18 ++ components/file-path-chip.tsx | 62 ++++ components/file-path-drop-zone.tsx | 106 +++++++ components/home.tsx | 13 +- components/new-session.tsx | 4 +- components/path-input-dom.ts | 245 ++++++++++++++ components/path-input.tsx | 291 +++++++++++++++++ components/practice-dock.tsx | 10 +- components/question-dock.tsx | 32 +- components/reading-thinking.tsx | 6 +- components/session-find-dom.ts | 2 +- components/sidebar.tsx | 5 +- components/thinking-block.tsx | 3 +- electron/preload.cjs | 5 +- scripts/smoke-file-paths.mjs | 424 +++++++++++++++++++++++++ scripts/smoke-opening-prompt.mjs | 17 +- src/core/file-paths.ts | 86 +++++ src/core/index.ts | 2 + tests/desktop-platform.test.ts | 2 +- tests/file-path-chip.test.tsx | 89 ++++++ tests/file-path-drop-zone.test.tsx | 223 +++++++++++++ tests/file-path-preload.test.ts | 93 ++++++ tests/file-paths.test.ts | 127 ++++++++ tests/path-input.test.tsx | 491 +++++++++++++++++++++++++++++ tests/render-cards.test.tsx | 2 +- tests/render-new-session.test.tsx | 2 +- tests/session-find.test.tsx | 3 +- 30 files changed, 2331 insertions(+), 59 deletions(-) create mode 100644 components/file-path-chip.tsx create mode 100644 components/file-path-drop-zone.tsx create mode 100644 components/path-input-dom.ts create mode 100644 components/path-input.tsx create mode 100644 scripts/smoke-file-paths.mjs create mode 100644 src/core/file-paths.ts create mode 100644 tests/file-path-chip.test.tsx create mode 100644 tests/file-path-drop-zone.test.tsx create mode 100644 tests/file-path-preload.test.ts create mode 100644 tests/file-paths.test.ts create mode 100644 tests/path-input.test.tsx diff --git a/components/answered-card.tsx b/components/answered-card.tsx index ce76b80..1b7c95c 100644 --- a/components/answered-card.tsx +++ b/components/answered-card.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { ConfidenceRail } from "@/components/confidence-rail"; import { GlyphMark } from "@/components/ui/glyph-mark"; import { ThinkingBlock } from "@/components/thinking-block"; +import { PathText } from "@/components/file-path-chip"; import { cn } from "@/lib/utils"; import { ToolContentResponse } from "@/components/ai-elements/message"; import type { QuestionAnswerData } from "@/src/core"; @@ -153,13 +154,13 @@ export function AnsweredCard({ answer: savedAnswer, input }: { answer: QuestionA data-testid="answered-other" className="flex items-center gap-2 rounded-lg border border-primary bg-accent px-3 py-1.5" > - “{answer.other}” + “”
)}
)} - {answer.text &&
“{answer.text}”
} + {answer.text &&
“”
} {/* The confidence record (owner round 6): the slider's rail, disc removed — the missing handle itself says "this is a record, not a diff --git a/components/chat.tsx b/components/chat.tsx index 2d851d1..8c70164 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -14,6 +14,7 @@ import { ConversationScrollButton, } from "@/components/ai-elements/conversation"; import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message"; +import { PathText } from "@/components/file-path-chip"; import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning"; import { Shimmer } from "@/components/ai-elements/shimmer"; import { BrailleSpinner } from "@/components/braille-spinner"; @@ -687,10 +688,10 @@ export function Chat({ const cycleChatFields = useCallback((): readonly HTMLElement[] => { const pick = (sel: string) => Array.from(document.querySelectorAll(sel)); return [ - ...pick('[data-testid="reading-thinking-field"] textarea'), - ...pick('[data-testid="question-dock"] textarea'), - ...pick('[data-testid="practice-dock"] textarea'), - ...pick('[data-testid="composer"] textarea'), + ...pick('[data-testid="reading-thinking-field"] [data-file-path-input]'), + ...pick('[data-testid="question-dock"] [data-file-path-input]'), + ...pick('[data-testid="practice-dock"] [data-file-path-input]'), + ...pick('[data-testid="composer"] [data-file-path-input]'), ]; }, []); useFieldCycle(cycleChatFields); @@ -705,7 +706,7 @@ export function Chat({ * composer: with a question pending the Composer is unmounted, the ref * is null, and the thinking field is left alone by design. */ const dictationWasActiveRef = useRef(false); - const composerInputRef = useRef(null); + const composerInputRef = useRef(null); useEffect(() => { if (dictation.active) { dictationWasActiveRef.current = true; @@ -1507,7 +1508,7 @@ export function Chat({ rail as the thinking rows — one quote geometry. */}
You
- {m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")} + p.type === "text").map((p) => p.text).join("\n")} />
) : ( diff --git a/components/composer.tsx b/components/composer.tsx index c5c7db4..649862d 100644 --- a/components/composer.tsx +++ b/components/composer.tsx @@ -3,6 +3,7 @@ import { ArrowRightIcon, CornerDownLeftIcon, SquareIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { Hint } from "@/components/ui/hint"; +import { PathInput } from "@/components/path-input"; /** * The chat composer — content row ONLY (the persistent rounded shell lives in @@ -59,7 +60,7 @@ export function Composer({ autoFocus?: boolean; /** The page's handle to the textarea — the dictation-end focus (owner, * 2026-10-01): a run ending hands the cursor to the bare composer. */ - inputRef?: React.RefObject; + inputRef?: React.RefObject; /** Escape while a dictation runs stops it (STT wave, 2026-09-17). No-op * when absent — Esc otherwise means nothing in the composer. */ onEscape?: () => void; @@ -90,10 +91,11 @@ export function Composer({ act(); }} > - +
needle path draft notes.pdf
`); const ranges = sessionFindRanges(document.body as unknown as HTMLElement, "needle"); assert.equal(ranges.length, 1); From 0b576d59731924063ad4b00bed2fb46b29597489 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 19:07:25 +0200 Subject: [PATCH 70/87] fix: preserve native word and line deletion around file chips --- components/path-input-dom.ts | 12 +++ components/path-input.tsx | 16 +++- tests/path-input.test.tsx | 140 ++++++++++++++++++++++++++++++++++- 3 files changed, 165 insertions(+), 3 deletions(-) diff --git a/components/path-input-dom.ts b/components/path-input-dom.ts index d8699f0..b352876 100644 --- a/components/path-input-dom.ts +++ b/components/path-input-dom.ts @@ -110,6 +110,18 @@ export function pathInputOffset(root: HTMLElement, node: Node, offset: number): return total; } +/** Validate a browser beforeinput StaticRange before projecting its exact endpoints. */ +export function pathInputStaticRange(root: HTMLElement, range: Pick): TextSelection | null { + function valid(node: Node, offset: number) { + return (node === root || root.contains(node)) && Number.isInteger(offset) && offset >= 0 + && offset <= (node.nodeType === 3 ? (node as Text).length : node.childNodes.length); + } + if (!valid(range.startContainer, range.startOffset) || !valid(range.endContainer, range.endOffset)) return null; + const anchor = pathInputOffset(root, range.startContainer, range.startOffset); + const focus = pathInputOffset(root, range.endContainer, range.endOffset); + return anchor === null || focus === null || anchor >= focus ? null : { anchor, focus }; +} + export function readPathSelection(root: HTMLElement): TextSelection | null { const selection = root.ownerDocument.getSelection?.(); if (!selection?.anchorNode || !selection.focusNode) return null; diff --git a/components/path-input.tsx b/components/path-input.tsx index bb85f1b..574b661 100644 --- a/components/path-input.tsx +++ b/components/path-input.tsx @@ -6,7 +6,7 @@ import { FilePathChip } from "./file-path-chip"; import { cn } from "../lib/utils"; import { adjacentPathRange, atomicPathRange, clampPathSelection, insertFilePaths, - normalizePathEdit, normalizePathInputValue, PathInputHistory, readPathInput, readPathSelection, renderPathInput, + normalizePathEdit, normalizePathInputValue, pathInputStaticRange, PathInputHistory, readPathInput, readPathSelection, renderPathInput, replacePathSelection, restorePathSelection, type PathEditSnapshot, type TextSelection, } from "./path-input-dom"; @@ -123,6 +123,18 @@ export const PathInput = React.forwardRef(functi // The cut handler owns both the full-source clipboard and deletion. // Never perform a second deletion if a browser also sends beforeinput. event.preventDefault(); + } else if (/^delete(?:(?:Word|SoftLine|HardLine)(?:Backward|Forward)|EntireSoftLine)$/.test(event.inputType)) { + // Modifier keys have platform-specific word/visual-line semantics. + // Only the browser knows that extent; never substitute an adjacent chip. + let targets: StaticRange[] = []; + try { targets = event.getTargetRanges?.() ?? []; } catch { /* Native fallback below. */ } + const range = targets.length === 1 ? pathInputStaticRange(root, targets[0]!) : null; + if (range && event.cancelable) { + event.preventDefault(); + // replacePathSelection expands any intersected chip atomically. + change(replacePathSelection(current, range, "")); + } + // Missing/invalid/non-single targets: native edit + input projection owns it. } else if (event.inputType.startsWith("delete")) { const range = adjacentPathRange(current, saved, event.inputType.includes("Backward") ? "backward" : "forward"); if (range) { @@ -151,7 +163,7 @@ export const PathInput = React.forwardRef(functi if ((event.metaKey || event.ctrlKey) && !event.altKey && (event.key.toLowerCase() === "z" || event.key.toLowerCase() === "y")) { event.preventDefault(); travel(event.key.toLowerCase() === "y" || event.shiftKey ? "redo" : "undo"); - } else if (event.key === "Backspace" || event.key === "Delete") { + } else if ((event.key === "Backspace" || event.key === "Delete") && !event.metaKey && !event.ctrlKey && !event.altKey) { const range = adjacentPathRange(current, saved, event.key === "Backspace" ? "backward" : "forward"); if (range) { event.preventDefault(); diff --git a/tests/path-input.test.tsx b/tests/path-input.test.tsx index c1ca6c9..e2980e3 100644 --- a/tests/path-input.test.tsx +++ b/tests/path-input.test.tsx @@ -6,7 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { PathInput, INSERT_FILE_PATHS_EVENT } from "../components/path-input.tsx"; import { PATH_CARET_ATTRIBUTE, PATH_SOURCE_ATTRIBUTE, PathInputHistory, adjacentPathRange, atomicPathRange, - insertFilePaths, normalizePathEdit, normalizePathInputValue, pathInputOffset, pathInputPoint, readPathInput, + insertFilePaths, normalizePathEdit, normalizePathInputValue, pathInputOffset, pathInputPoint, pathInputStaticRange, readPathInput, readPathSelection, renderPathInput, replacePathSelection, restorePathSelection, } from "../components/path-input-dom.ts"; import { quoteFilePath } from "../src/core/file-paths.ts"; @@ -228,6 +228,144 @@ test("native typing, token insertion/deletion and undo/redo share one history", assert.equal(f.value, "ab"); }); +test("Cmd+Backspace delegates line extent to beforeinput instead of deleting just the final chip", async () => { + const source = "Explain `/tmp/notes.pdf`"; + const f = await fixture(source); + f.select(source.length); + const key = await f.event("keydown", { key: "Backspace", metaKey: true }); + assert.equal(key.defaultPrevented, false); + assert.equal(f.value, source); + const deletion = await f.event("beforeinput", { + inputType: "deleteSoftLineBackward", + getTargetRanges: () => [{ startContainer: f.root.firstChild!, startOffset: 0, endContainer: f.root, endOffset: f.root.childNodes.length }], + }); + assert.equal(deletion.defaultPrevented, true); + assert.equal(f.value, ""); + await f.event("keydown", { key: "z", metaKey: true }); + assert.equal(f.value, source); + assert.deepEqual(readPathSelection(f.root), { anchor: source.length, focus: source.length }); +}); + +test("native word and soft/hard line deletion ranges retain mixed text/chip extents in both directions", async () => { + const source = "keep old `/tmp/notes.pdf` tail\nnext"; + const f = await fixture(source); + const lineEnd = source.indexOf("\n"); + const chipEnd = source.indexOf(" tail"); + const cases = [ + ["deleteWordBackward", 5, lineEnd], ["deleteWordForward", 5, chipEnd], + ["deleteSoftLineBackward", 0, lineEnd], ["deleteSoftLineForward", 5, lineEnd], + ["deleteHardLineBackward", 0, chipEnd], ["deleteHardLineForward", 5, lineEnd], + ] as const; + for (const [inputType, start, end] of cases) { + await f.external(source); + const backward = inputType.endsWith("Backward"); + f.select(backward ? end : start); + const key = await f.event("keydown", { key: backward ? "Backspace" : "Delete", ctrlKey: true }); + assert.equal(key.defaultPrevented, false, inputType); + assert.equal(f.value, source); + const a = pathInputPoint(f.root, start); + const b = pathInputPoint(f.root, end); + const deletion = await f.event("beforeinput", { + inputType, + getTargetRanges: () => [{ startContainer: a.node, startOffset: a.offset, endContainer: b.node, endOffset: b.offset }], + }); + assert.equal(deletion.defaultPrevented, true, inputType); + assert.equal(f.value, source.slice(0, start) + source.slice(end), inputType); + assert.deepEqual(readPathSelection(f.root), { anchor: start, focus: start }); + } +}); + +test("deleteEntireSoftLine honors the browser extent and never substitutes a chip when ranges are absent", async () => { + const source = "before\nExplain `/tmp/notes.pdf` tail\nafter"; + const f = await fixture(source); + const start = source.indexOf("Explain"); + const end = source.indexOf("\nafter"); + f.select(source.indexOf("`")); // Adjacent to a chip, but not a chip-only command. + for (const getTargetRanges of [undefined, () => []]) { + const missing = await f.event("beforeinput", { inputType: "deleteEntireSoftLine", getTargetRanges }); + assert.equal(missing.defaultPrevented, false); + assert.equal(f.value, source); + } + const a = pathInputPoint(f.root, start); + const b = pathInputPoint(f.root, end); + const deletion = await f.event("beforeinput", { + inputType: "deleteEntireSoftLine", + getTargetRanges: () => [{ startContainer: a.node, startOffset: a.offset, endContainer: b.node, endOffset: b.offset }], + }); + assert.equal(deletion.defaultPrevented, true); + assert.equal(f.value, "before\n\nafter"); + assert.deepEqual(readPathSelection(f.root), { anchor: start, focus: start }); +}); + +test("browser target ranges that intersect a visual chip interior expand the complete source atomically", async () => { + const source = "keep old `/tmp/notes.pdf` tail"; + const f = await fixture(source); + f.select(5); + const label = f.root.querySelector(`[${PATH_SOURCE_ATTRIBUTE}] [role=img] span`)!.firstChild!; + const start = pathInputPoint(f.root, 5); + const target = { startContainer: start.node, startOffset: start.offset, endContainer: label, endOffset: 2 }; + const mapped = pathInputStaticRange(f.root, target)!; + assert.ok(mapped.focus > source.indexOf("`")); + assert.ok(mapped.focus < source.lastIndexOf("`") + 1); + await f.event("beforeinput", { inputType: "deleteWordForward", getTargetRanges: () => [target] }); + assert.equal(f.value, "keep tail"); + assert.equal(f.root.querySelector(`[${PATH_SOURCE_ATTRIBUTE}]`), null); +}); + +test("missing or invalid word/line target ranges defer to native editing and preserve owned undo", async () => { + const source = "Explain `/tmp/notes.pdf`"; + const f = await fixture(source); + f.select(source.length); + for (const modifier of ["metaKey", "ctrlKey", "altKey"]) { + for (const key of ["Backspace", "Delete"]) { + const event = await f.event("keydown", { key, [modifier]: true }); + assert.equal(event.defaultPrevented, false); + assert.equal(f.value, source); + } + } + for (const inputType of ["deleteWordBackward", "deleteWordForward", "deleteSoftLineBackward", "deleteSoftLineForward", "deleteHardLineBackward", "deleteHardLineForward"]) { + for (const getTargetRanges of [undefined, () => []]) { + const beforeInput = await f.event("beforeinput", { inputType, getTargetRanges }); + assert.equal(beforeInput.defaultPrevented, false, `${inputType} must not fall through to adjacent-chip deletion`); + assert.equal(f.value, source); + } + } + const valid = { startContainer: f.root, startOffset: 0, endContainer: f.root, endOffset: f.root.childNodes.length }; + for (const getTargetRanges of [ + undefined, () => [], () => [{ ...valid, startContainer: f.doc.body }], + () => [{ ...valid, startOffset: -1 }], () => [{ ...valid, endOffset: 999 }], + () => [{ ...valid, endOffset: 0 }], () => [valid, valid], () => { throw new Error("not available"); }, + ]) { + const beforeInput = await f.event("beforeinput", { inputType: "deleteWordBackward", getTargetRanges }); + assert.equal(beforeInput.defaultPrevented, false); + assert.equal(f.value, source); + } + // With no usable target ranges the browser edits the DOM, then input projects it. + await f.nativeText("", "deleteHardLineBackward"); + assert.equal(f.value, ""); + await f.event("keydown", { key: "z", metaKey: true }); + assert.equal(f.value, source); +}); + +test("ordinary backward/forward chip deletion and selected ranges retain their existing behavior", async () => { + const source = "a `/tmp/notes.pdf` b"; + const f = await fixture(source); + f.select(source.length - 2); + const backspace = await f.event("keydown", { key: "Backspace" }); + assert.equal(backspace.defaultPrevented, true); + assert.equal(f.value, "a b"); + await f.external(source); + f.select(2); + const forward = await f.event("keydown", { key: "Delete" }); + assert.equal(forward.defaultPrevented, true); + assert.equal(f.value, "a b"); + await f.external(source); + f.select(source.length, 0); + const selected = await f.event("keydown", { key: "Backspace" }); + assert.equal(selected.defaultPrevented, true); + assert.equal(f.value, ""); +}); + test("immutable SSR fallback never stomps native edits, controlled updates, chips or reset", async () => { const initial = "Restored draft `/course/original.pdf`"; const f = await fixture(initial); From c5a756d7076144b076cbd20ac79bb4a26f61a053 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 19:10:47 +0200 Subject: [PATCH 71/87] feat: report Claude CLI installation and gate unsupported versions --- app/api/claude-code/route.ts | 6 ++ components/settings/claude-code-status.tsx | 49 +++++++++++++ components/settings/keys-section.tsx | 5 ++ src/agent/claude-code/installation.ts | 38 ++++++++++ src/agent/claude-code/launch.ts | 32 +++++++++ src/agent/claude-code/stream.ts | 21 +++--- src/core/contracts/claude-code.ts | 10 +++ src/core/index.ts | 1 + src/server/claude-code.ts | 8 +++ src/server/index.ts | 1 + src/server/keys.ts | 4 ++ tests/chatgpt-connection-ui.test.tsx | 6 +- tests/claude-code-harness.test.ts | 8 ++- tests/claude-code-installation.test.ts | 72 +++++++++++++++++++ tests/claude-code-status-ui.test.tsx | 81 ++++++++++++++++++++++ tests/claude-code-stream.test.ts | 6 +- tests/model-catalog.test.ts | 7 +- 17 files changed, 334 insertions(+), 21 deletions(-) create mode 100644 app/api/claude-code/route.ts create mode 100644 components/settings/claude-code-status.tsx create mode 100644 src/agent/claude-code/installation.ts create mode 100644 src/agent/claude-code/launch.ts create mode 100644 src/core/contracts/claude-code.ts create mode 100644 src/server/claude-code.ts create mode 100644 tests/claude-code-installation.test.ts create mode 100644 tests/claude-code-status-ui.test.tsx diff --git a/app/api/claude-code/route.ts b/app/api/claude-code/route.ts new file mode 100644 index 0000000..e587386 --- /dev/null +++ b/app/api/claude-code/route.ts @@ -0,0 +1,6 @@ +import { claudeCodeInstallationStatus } from "@/src/server"; + +/** Installation/version only; never reads auth status or makes a model request. */ +export async function GET() { + return Response.json(await claudeCodeInstallationStatus(), { headers: { "Cache-Control": "no-store" } }); +} diff --git a/components/settings/claude-code-status.tsx b/components/settings/claude-code-status.tsx new file mode 100644 index 0000000..274d5b4 --- /dev/null +++ b/components/settings/claude-code-status.tsx @@ -0,0 +1,49 @@ +"use client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "@/src/core"; +import { ConnectionRow } from "./connection-controls.tsx"; + +export function claudeCodeStatusLabel(status: ClaudeCodeStatus | null): string { + switch (status?.state) { + case "not-found": return "Not found"; + case "meets-minimum": return `Version ${status.version} · meets minimum`; + case "update-required": return `Version ${status.version} · update required`; + default: return "Version could not be checked"; + } +} + +export function ClaudeCodeInstallationStatus() { + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(true); + const request = useRef(null); + const refresh = useCallback(async () => { + request.current?.abort(); + const controller = new AbortController(); + request.current = controller; + setBusy(true); + try { + const response = await fetch("/api/claude-code", { cache: "no-store", signal: controller.signal }); + if (!response.ok) throw new Error("status unavailable"); + const value = await response.json() as ClaudeCodeStatus; + if (!["not-found", "meets-minimum", "update-required", "version-unchecked"].includes(value.state) + || value.minimumVersion !== CLAUDE_CODE_MINIMUM_VERSION + || (value.version !== null && (typeof value.version !== "string" || !/^\d+\.\d+\.\d+$/.test(value.version)))) throw new Error("invalid status"); + if (!controller.signal.aborted) setStatus(value); + } catch { + if (!controller.signal.aborted) setStatus(null); + } finally { + if (!controller.signal.aborted) setBusy(false); + } + }, []); + useEffect(() => { + void refresh(); + return () => request.current?.abort(); + }, [refresh]); + return
+ void refresh()} actionTestid="claude-code-check-again"> + {busy ? "Checking…" : claudeCodeStatusLabel(status)} + +

Install and authenticate in Claude Code outside LearnHarness. Authentication is not checked. Your Claude Code configuration determines billing.

+

Minimum version: {CLAUDE_CODE_MINIMUM_VERSION}. Meeting this minimum does not guarantee MCP compatibility, model access, or available quota. Claude Code setup ↗

+
; +} diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 198e93d..5d5f18e 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -27,6 +27,7 @@ import { useEffect, useRef, useState } from "react"; import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core"; import { SettingsGroup } from "./controls.tsx"; import { SubscriptionConnections } from "./subscription-connections.tsx"; +import { ClaudeCodeInstallationStatus } from "./claude-code-status.tsx"; import { ProviderMenuList } from "./model-menu.tsx"; import { MenuShell } from "@/components/menu-shell"; import { notifySettingsSaved } from "@/components/settings-signal"; @@ -471,6 +472,10 @@ export function KeysSection({ + + + + {(["Dictation", "Search"] as const).map((label) => {toolRows.filter((status) => label === "Dictation" ? status.id === "DEEPGRAM_API_KEY" : status.id !== "DEEPGRAM_API_KEY").map((status) => ( Promise; + +/** No shell, auth-status command, model call, or credential-file inspection. */ +export const readClaudeVersion: ReadClaudeVersion = (binary) => new Promise((resolve, reject) => { + execFile(binary, ["--version"], { + cwd: tmpdir(), env: externalCliEnv(), windowsHide: true, + timeout: 5_000, killSignal: "SIGKILL", maxBuffer: 4_096, + }, (error, stdout) => error ? reject(error) : resolve(stdout)); +}); + +export async function inspectClaudeInstallation( + findBinary: () => string | null = getClaudeBinary, + readVersion: ReadClaudeVersion = readClaudeVersion, +): Promise { + const base = { version: null, minimumVersion: CLAUDE_CODE_MINIMUM_VERSION }; + const binary = findBinary(); + if (!binary) return { ...base, state: "not-found" }; + try { + const output = await readVersion(binary); + // Expose only a version, never arbitrary CLI stdout/stderr or an absolute path. + const match = /^(\d+)\.(\d+)\.(\d+)(?: \(Claude Code\))?$/.exec(output.trim()); + if (!match) return { ...base, state: "version-unchecked" }; + const parts = match.slice(1).map(Number); + if (!parts.every(Number.isSafeInteger)) return { ...base, state: "version-unchecked" }; + const minimum = CLAUDE_CODE_MINIMUM_VERSION.split(".").map(Number); + const differing = parts.findIndex((part, index) => part !== minimum[index]); + const meetsMinimum = differing === -1 || parts[differing]! > minimum[differing]!; + return { ...base, version: parts.join("."), state: meetsMinimum ? "meets-minimum" : "update-required" }; + } catch { + return { ...base, state: "version-unchecked" }; + } +} diff --git a/src/agent/claude-code/launch.ts b/src/agent/claude-code/launch.ts new file mode 100644 index 0000000..5b296bd --- /dev/null +++ b/src/agent/claude-code/launch.ts @@ -0,0 +1,32 @@ +import { spawn } from "node:child_process"; +import { getClaudeBinary } from "./binary.ts"; +import { inspectClaudeInstallation, readClaudeVersion, type ReadClaudeVersion } from "./installation.ts"; +import type { RunClaude } from "./stream.ts"; + +export class ClaudeLaunchError extends Error {} + +/** Injectable seams keep all verification offline; production uses the installed CLI. */ +export function createClaudeLauncher({ + findBinary = getClaudeBinary, + readVersion = readClaudeVersion, + spawnProcess = spawn, +}: { + findBinary?: () => string | null; + readVersion?: ReadClaudeVersion; + spawnProcess?: typeof spawn; +} = {}): RunClaude { + return async (args, options) => { + if (options.signal?.aborted) throw new ClaudeLaunchError("Request was aborted"); + const binary = findBinary(); + if (!binary) return null; + const status = await inspectClaudeInstallation(() => binary, readVersion); + if (status.state === "update-required") { + throw new ClaudeLaunchError(`Claude Code ${status.version} requires an update. LearnHarness requires at least ${status.minimumVersion}; update Claude Code outside LearnHarness.`); + } + if (status.state !== "meets-minimum") { + throw new ClaudeLaunchError("Claude Code's version could not be checked. Check your installation outside LearnHarness and try again."); + } + if (options.signal?.aborted) throw new ClaudeLaunchError("Request was aborted"); + return spawnProcess(binary, args, { cwd: options.cwd, env: options.env, stdio: "pipe" }); + }; +} diff --git a/src/agent/claude-code/stream.ts b/src/agent/claude-code/stream.ts index 4eda283..39a49ef 100644 --- a/src/agent/claude-code/stream.ts +++ b/src/agent/claude-code/stream.ts @@ -1,4 +1,4 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { type ChildProcessWithoutNullStreams } from "node:child_process"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -24,7 +24,7 @@ import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messag import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse"; import { externalCliEnv } from "../../core/auth-env.ts"; -import { getClaudeBinary } from "./binary.ts"; +import { ClaudeLaunchError, createClaudeLauncher } from "./launch.ts"; import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts"; import { ClaudeCodeError } from "./constants.ts"; import { addLiveProcess, getLiveProcess, removeLiveProcess, type LiveProcess } from "./live-processes.ts"; @@ -32,9 +32,10 @@ import { configurationForReuse, conversationForReuse } from "./reuse-context.ts" import { BRIDGE_SERVER_NAME, createToolBridge, toolNameFromMcp, type PendingToolCall, type ToolBridge } from "./tool-bridge.ts"; import { toClaudeInput, toStreamJsonLine } from "./transcript.ts"; -type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv }; +type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv; signal?: AbortSignal }; -export type RunClaude = (args: string[], options: RunClaudeOptions) => ChildProcessWithoutNullStreams | null; +type ClaudeChild = ChildProcessWithoutNullStreams | null; +export type RunClaude = (args: string[], options: RunClaudeOptions) => ClaudeChild | Promise; // A held MCP call lasts until pi calls back with the tool result, which for a question tool is // whenever the learner answers; this must outlast the live-process idle timeout so the CLI never @@ -637,7 +638,7 @@ async function startProcess( mcpConfigFile: files.mcpConfigFile, allowedTools: bridge.allowedTools, }); - child = runClaude(args, { cwd: files.cwd, env }); + child = await runClaude(args, { cwd: files.cwd, env, signal: options?.signal }); } catch (error) { await removeTurnResources(bridge, files); throw error; @@ -666,17 +667,11 @@ export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunc startProcess(runClaude, turn, model, context, options, owner, configuration, env).catch((error: unknown) => { console.error("claude CLI turn failed:", error); - turn.fail(START_FAILED_MESSAGE, "error"); + turn.fail(options?.signal?.aborted ? ABORT_MESSAGE : error instanceof ClaudeLaunchError ? error.message : START_FAILED_MESSAGE, options?.signal?.aborted ? "aborted" : "error"); }); return stream; }; } -function spawnClaude(args: string[], options: RunClaudeOptions): ChildProcessWithoutNullStreams | null { - const binary = getClaudeBinary(); - - return binary ? spawn(binary, args, { ...options, stdio: "pipe" }) : null; -} - -export const streamClaudeCode = createClaudeCodeStreamFunction(spawnClaude); +export const streamClaudeCode = createClaudeCodeStreamFunction(createClaudeLauncher()); diff --git a/src/core/contracts/claude-code.ts b/src/core/contracts/claude-code.ts new file mode 100644 index 0000000..50abd27 --- /dev/null +++ b/src/core/contracts/claude-code.ts @@ -0,0 +1,10 @@ +/** Installation/version only. Never an authentication, quota, or billing verdict. */ +export interface ClaudeCodeStatus { + state: "not-found" | "meets-minimum" | "update-required" | "version-unchecked"; + version: string | null; + minimumVersion: string; +} + +// --permission-prompts none was added in 2.1.259. This is not an official +// minimum-version/stability contract for claudecode/toolUseId; admission fails closed. +export const CLAUDE_CODE_MINIMUM_VERSION = "2.1.259"; diff --git a/src/core/index.ts b/src/core/index.ts index bae8b33..4ca5f03 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -54,6 +54,7 @@ export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts export { NATIVE_SUBSCRIPTIONS } from "./contracts/subscriptions.ts"; export type { NativeSubscriptionProviderId, NativeSubscriptionLogicalId, NativeSubscriptionStatus, NativeSubscriptionSnapshot, NativeSubscriptionScope, NativeSubscriptionControlBridge, SubscriptionInteraction, SubscriptionPrompt } from "./contracts/subscriptions.ts"; export * from "./cost-summary.ts"; +export { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "./contracts/claude-code.ts"; export * from "./fuzzy.ts"; // settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19): // the user-settings record's shape/sanitizer (pure) and its fully-resolved diff --git a/src/server/claude-code.ts b/src/server/claude-code.ts new file mode 100644 index 0000000..be52c06 --- /dev/null +++ b/src/server/claude-code.ts @@ -0,0 +1,8 @@ +import { inspectClaudeInstallation } from "../agent/claude-code/installation.ts"; +import { initAuthEnv } from "./keys.ts"; + +/** The Settings status seam: initialize provenance before any child env is copied. */ +export async function claudeCodeInstallationStatus() { + initAuthEnv(); + return inspectClaudeInstallation(); +} diff --git a/src/server/index.ts b/src/server/index.ts index a04dfb5..cd65868 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -45,3 +45,4 @@ export * from "./observability.ts"; export * from "./model-catalog.ts"; export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts"; export { sessionCost } from "./pricing.ts"; +export { claudeCodeInstallationStatus } from "./claude-code.ts"; diff --git a/src/server/keys.ts b/src/server/keys.ts index 7f65362..03b8d4c 100644 --- a/src/server/keys.ts +++ b/src/server/keys.ts @@ -53,6 +53,8 @@ import { } from "../core/auth-keys.ts"; import { authStore } from "../store/auth.ts"; import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts"; +import { getClaudeBinary } from "../agent/claude-code/binary.ts"; +import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts"; // Shared HMR-stable provenance lets external CLIs exclude file-only injections // without importing this store-backed module into the agent layer. @@ -187,6 +189,8 @@ export function keyValue(id: string): string | undefined { * (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take * no key at all: nothing to gate, present by definition. */ export function providerKeyPresent(provider: string): boolean { + // Presence only, not authentication readiness. Launch performs the version gate. + if (provider === CLAUDE_CODE_PROVIDER) return getClaudeBinary() !== null; if (isSubscriptionProvider(provider)) return subscriptionConnected(provider); const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider); return info ? keyValue(info.envVar) !== undefined : true; diff --git a/tests/chatgpt-connection-ui.test.tsx b/tests/chatgpt-connection-ui.test.tsx index 2edcf2f..b329ba7 100644 --- a/tests/chatgpt-connection-ui.test.tsx +++ b/tests/chatgpt-connection-ui.test.tsx @@ -77,14 +77,16 @@ test("Providers is the first/default page; General is last and other pages keep assert.doesNotMatch(html, />Model providers<\/button>/); }); -test("Providers has exactly four ordered sections; unknown variables stay masked/read-only at the end of Model API keys", async () => { +test("Providers has five ordered sections including External CLI; unknown variables stay masked/read-only at the end of Model API keys", async () => { globalThis.fetch = async (url) => { + if (url === "/api/claude-code") return Response.json({ state: "not-found", version: null, minimumVersion: "2.1.259" }); assert.equal(url, "/api/keys"); return Response.json({ providers: [{ id: "openai", envVar: "OPENAI_API_KEY", label: "OpenAI" }], keys: [{ id: "FUTURE_VAR", info: null, present: true, masked: "…abcd", source: "file", shadowedByEnv: false }] }); }; fixture(); await mount(); const headings = [...document.querySelectorAll('[data-testid^="group-"] > p')].map((el) => el.textContent); - assert.deepEqual(headings, ["Model API keys", "Subscriptions", "Dictation", "Search"]); + assert.deepEqual(headings, ["Model API keys", "Subscriptions", "External CLI", "Dictation", "Search"]); + assert(byTest("claude-code-installation").closest('[data-testid="group-external-cli"]')); assert(byTest("key-deepgram_api_key").closest('[data-testid="group-dictation"]')); assert(byTest("key-tavily_api_key").closest('[data-testid="group-search"]')); assert(byTest("key-brave_api_key").closest('[data-testid="group-search"]')); diff --git a/tests/claude-code-harness.test.ts b/tests/claude-code-harness.test.ts index aab05bb..875c96c 100644 --- a/tests/claude-code-harness.test.ts +++ b/tests/claude-code-harness.test.ts @@ -12,7 +12,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { once } from "node:events"; import { mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import test, { type TestContext } from "node:test"; @@ -63,6 +63,12 @@ function toAskScenario(): string[] { function createFakeRun(t: TestContext, scenarios: string[][]): FakeRun { const dir = mkdtempSync(join(tmpdir(), "claude-code-harness-")); t.after(() => rmSync(dir, { recursive: true, force: true })); + // Satisfy the real presence gate; this marker is NEVER executed. All work + // below goes through the injected Node fake-CLI seam, not an installed CLI. + writeFileSync(join(dir, process.platform === "win32" ? "claude.exe" : "claude"), "", { mode: 0o755 }); + const savedPath = process.env.PATH; + process.env.PATH = `${dir}${delimiter}${savedPath ?? ""}`; + t.after(() => { if (savedPath === undefined) delete process.env.PATH; else process.env.PATH = savedPath; }); const children: ChildProcessWithoutNullStreams[] = []; const stdinFiles: string[] = []; diff --git a/tests/claude-code-installation.test.ts b/tests/claude-code-installation.test.ts new file mode 100644 index 0000000..cc33126 --- /dev/null +++ b/tests/claude-code-installation.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { type ChildProcessWithoutNullStreams, type spawn } from "node:child_process"; +import { test } from "node:test"; +import { inspectClaudeInstallation } from "../src/agent/claude-code/installation.ts"; +import { ClaudeLaunchError, createClaudeLauncher } from "../src/agent/claude-code/launch.ts"; + +const binary = "/fake/claude"; +const options = { cwd: "/fake/work", env: { NODE_ENV: "test" as const, ANTHROPIC_API_KEY: "native-test-key" } }; + +test("missing installation does not execute a command", async () => { + assert.deepEqual(await inspectClaudeInstallation(() => null, async () => { throw new Error("must not run"); }), { + state: "not-found", version: null, minimumVersion: "2.1.259", + }); +}); + +for (const [output, state, version] of [ + ["2.1.258 (Claude Code)\n", "update-required", "2.1.258"], + ["2.1.259 (Claude Code)", "meets-minimum", "2.1.259"], + ["2.1.310 (Claude Code)", "meets-minimum", "2.1.310"], + ["2.2.0", "meets-minimum", "2.2.0"], + ["2.0.999", "update-required", "2.0.999"], + ["3.0.0", "meets-minimum", "3.0.0"], + ["unexpected output containing private details", "version-unchecked", null], + ["2.1.310-preview", "version-unchecked", null], + ["99999999999999999999999999.1.1", "version-unchecked", null], +] as const) test(`installation version output ${JSON.stringify(output)} gives ${state}`, async () => { + const status = await inspectClaudeInstallation(() => binary, async (path) => { assert.equal(path, binary); return output; }); + assert.deepEqual(status, { state, version, minimumVersion: "2.1.259" }); +}); + +test("probe failures expose neither raw errors nor binary paths", async () => { + const status = await inspectClaudeInstallation(() => binary, async () => { throw new Error("timeout /private/path secret-token"); }); + assert.deepEqual(status, { state: "version-unchecked", version: null, minimumVersion: "2.1.259" }); +}); + +for (const output of ["2.1.258", "garbage", null]) test(`launcher refuses ${output ?? "failed"} version check before model work`, async () => { + const launch = createClaudeLauncher({ findBinary: () => binary, + readVersion: async () => { if (output === null) throw new Error("failed probe"); return output; }, + spawnProcess: (() => { assert.fail("must not start model work"); }) as typeof spawn, + }); + await assert.rejects(launch(["-p"], options) as Promise, ClaudeLaunchError); +}); + +test("launcher uses the verified executable directly and preserves model args and native env", async () => { + const child = {} as ChildProcessWithoutNullStreams; + let calls = 0; + const launch = createClaudeLauncher({ findBinary: () => binary, readVersion: async () => "2.1.310 (Claude Code)", + spawnProcess: ((path: string, args: string[], opts: unknown) => { + calls++; + assert.equal(path, binary); + assert.deepEqual(args, ["-p", "--tools", ""]); + assert.deepEqual(opts, { ...options, stdio: "pipe" }); + return child; + }) as typeof spawn, + }); + assert.equal(await launch(["-p", "--tools", ""], options), child); + assert.equal(calls, 1); +}); + +test("aborting during a version check prevents model process creation", async () => { + const controller = new AbortController(); + const launch = createClaudeLauncher({ findBinary: () => binary, + readVersion: async () => { controller.abort(); return "2.1.310"; }, + spawnProcess: (() => { assert.fail("must not start after abort"); }) as typeof spawn, + }); + await assert.rejects(launch([], { ...options, signal: controller.signal }) as Promise, /aborted/); +}); + +test("missing executable is not mistaken for an unchecked version", async () => { + const launch = createClaudeLauncher({ findBinary: () => null, readVersion: async () => assert.fail("must not probe") }); + assert.equal(await launch([], options), null); +}); diff --git a/tests/claude-code-status-ui.test.tsx b/tests/claude-code-status-ui.test.tsx new file mode 100644 index 0000000..5c09ddb --- /dev/null +++ b/tests/claude-code-status-ui.test.tsx @@ -0,0 +1,81 @@ +import React, { act } from "react"; +(globalThis as Record).React = React; +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { createRoot, type Root } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; +import { parseHTML } from "linkedom"; +import { ClaudeCodeInstallationStatus, claudeCodeStatusLabel } from "../components/settings/claude-code-status.tsx"; +import type { ClaudeCodeStatus } from "../src/core/contracts/claude-code.ts"; + +const dom = parseHTML(""); +Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }); +const originalFetch = globalThis.fetch; +let root: Root | undefined, host: HTMLDivElement | undefined; +const status = (state: ClaudeCodeStatus["state"], version: string | null = null): ClaudeCodeStatus => ({ state, version, minimumVersion: "2.1.259" }); +const button = () => document.querySelector('[data-testid="claude-code-check-again"]')!; +const label = () => document.querySelector('[role="status"]')?.textContent; +async function mount() { + host = document.createElement("div"); document.body.append(host); root = createRoot(host); + await act(async () => root!.render()); +} +afterEach(async () => { + await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; + globalThis.fetch = originalFetch; +}); + +test("row is informational, disclaims auth/billing readiness, and links only to official setup", () => { + const html = renderToStaticMarkup(); + assert.match(html, /Authentication is not checked/); + assert.match(html, /configuration determines billing/); + assert.match(html, /does not guarantee MCP compatibility/); + assert.match(html, /https:\/\/code.claude.com\/docs\/en\/setup/); + assert.equal((html.match(/")); +}); + +for (const [value, text] of [ + [status("not-found"), "Not found"], + [status("meets-minimum", "2.1.310"), "Version 2.1.310 · meets minimum"], + [status("update-required", "2.1.258"), "Version 2.1.258 · update required"], + [status("version-unchecked"), "Version could not be checked"], +] as const) test(`installation display: ${text}`, () => assert.equal(claudeCodeStatusLabel(value), text)); + +test("mount checks once, Check again refreshes, and neither request asks for authentication", async () => { + const calls: string[] = []; + globalThis.fetch = (async (url, init) => { + calls.push(String(url)); + assert.equal(init?.cache, "no-store"); + return Response.json(calls.length === 1 ? status("not-found") : status("meets-minimum", "2.1.310")); + }) as typeof fetch; + await mount(); + assert.equal(label(), "Not found"); + await act(async () => button().click()); + assert.equal(label(), "Version 2.1.310 · meets minimum"); + assert.deepEqual(calls, ["/api/claude-code", "/api/claude-code"]); +}); + +test("slow checks disable recheck and unmount aborts outstanding work", async () => { + let signal: AbortSignal | null | undefined; + let resolve!: (response: Response) => void; + globalThis.fetch = ((_url, init) => { signal = init?.signal; return new Promise((done) => { resolve = done; }); }) as typeof fetch; + await mount(); + assert.equal(label(), "Checking…"); assert(button().disabled); + await act(async () => { root!.unmount(); root = undefined; }); + assert(signal?.aborted); + await act(async () => resolve(Response.json(status("not-found")))); + assert.equal(document.querySelector('[role="status"]'), null); +}); + +for (const failure of ["network", "http", "payload"] as const) test(`${failure} failure never retains a stale meets-minimum status`, async () => { + let calls = 0; + globalThis.fetch = (async () => { + if (++calls === 1) return Response.json(status("meets-minimum", "2.1.310")); + if (failure === "network") throw new Error("offline"); + return failure === "http" ? new Response(null, { status: 503 }) : Response.json({ state: "authenticated" }); + }) as typeof fetch; + await mount(); assert.equal(label(), "Version 2.1.310 · meets minimum"); + await act(async () => button().click()); + assert.equal(label(), "Version could not be checked"); assert(!button().disabled); +}); diff --git a/tests/claude-code-stream.test.ts b/tests/claude-code-stream.test.ts index 67885c7..19e3a76 100644 --- a/tests/claude-code-stream.test.ts +++ b/tests/claude-code-stream.test.ts @@ -37,6 +37,7 @@ import { ClaudeCodeError } from "../src/agent/claude-code/constants.ts"; import { LIVE_PROCESS_IDLE_TIMEOUT_MS, liveProcessIdleTimeoutMs } from "../src/agent/claude-code/live-processes.ts"; import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; import { authEnvRecord } from "../src/core/auth-env.ts"; +import { ClaudeLaunchError } from "../src/agent/claude-code/launch.ts"; import { createClaudeCodeStreamFunction, toClaudeArgs, toEffort, type RunClaude } from "../src/agent/claude-code/stream.ts"; import { toStreamJsonLine } from "../src/agent/claude-code/transcript.ts"; @@ -899,15 +900,16 @@ test("normal completion escalates termination when the child ignores SIGTERM", a assert.equal(getOnlyCall(calls).child.signalCode, "SIGKILL"); }); -test("a synchronous spawn failure removes both temp directories and the bridge", async () => { +for (const asyncVersionFailure of [false, true]) test(`${asyncVersionFailure ? "an asynchronous version-check" : "a synchronous spawn"} failure removes both temp directories and the bridge`, async () => { let resources: { cwd: string; privateDir: string; url: string } | undefined; const stream = createClaudeCodeStreamFunction((args, options) => { const config = JSON.parse(readFileSync(getFlagValue(args, "--mcp-config"), "utf8")); resources = { cwd: options.cwd, privateDir: dirname(getFlagValue(args, "--mcp-config")), url: config.mcpServers.learnharness.url }; + if (asyncVersionFailure) return Promise.reject(new ClaudeLaunchError("Claude Code requires an update")); throw new Error("synthetic spawn failure"); }); const events = await collectEvents(stream(getModel(), normalizeContext({ messages: [HI] }))); - assert.match(getErrorMessage(events) ?? "", /failed to start/); + assert.match(getErrorMessage(events) ?? "", asyncVersionFailure ? /requires an update/ : /failed to start/); assert.ok(resources); assert.equal(existsSync(resources.cwd), false); assert.equal(existsSync(resources.privateDir), false); diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index b3de078..62b14fc 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -354,7 +354,7 @@ function withClaudeInstalled(installed: boolean, fn: () => void): void { } } -describe("the claude-code provider (subscription CLI, no key)", () => { +describe("the claude-code provider (native CLI authentication, no LearnHarness key)", () => { it("modelCatalog() lists claude-code opus/sonnet/haiku when the CLI is installed", () => { withClaudeInstalled(true, () => { const ids = modelCatalog() @@ -394,7 +394,8 @@ describe("the claude-code provider (subscription CLI, no key)", () => { assert.equal(engine.model.id, "sonnet"); }); - it('providerKeyPresent("claude-code") is true', () => { - assert.equal(providerKeyPresent("claude-code"), true); + it('providerKeyPresent("claude-code") follows executable presence, not a generic keyless default', () => { + withClaudeInstalled(true, () => assert.equal(providerKeyPresent("claude-code"), true)); + withClaudeInstalled(false, () => assert.equal(providerKeyPresent("claude-code"), false)); }); }); From 5093809ea08ebcec3675d3ae208df3173238b607 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 19:26:36 +0200 Subject: [PATCH 72/87] fix: authorize CLI probes and preserve executable identity --- app/api/claude-code/route.ts | 6 +-- components/settings/claude-code-status.tsx | 2 +- src/agent/claude-code/binary.ts | 7 ++- src/server/claude-code.ts | 19 +++++++ src/server/index.ts | 2 +- tests/claude-code-binary.test.ts | 6 +++ tests/claude-code-status-http.test.ts | 39 ++++++++++++++ tests/claude-code-status-ui.test.tsx | 1 + tests/claude-code-version-runner.test.ts | 63 ++++++++++++++++++++++ 9 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 tests/claude-code-status-http.test.ts create mode 100644 tests/claude-code-version-runner.test.ts diff --git a/app/api/claude-code/route.ts b/app/api/claude-code/route.ts index e587386..1708fe9 100644 --- a/app/api/claude-code/route.ts +++ b/app/api/claude-code/route.ts @@ -1,6 +1,6 @@ -import { claudeCodeInstallationStatus } from "@/src/server"; +import { claudeCodeInstallationResponse } from "@/src/server"; /** Installation/version only; never reads auth status or makes a model request. */ -export async function GET() { - return Response.json(await claudeCodeInstallationStatus(), { headers: { "Cache-Control": "no-store" } }); +export async function GET(request: Request) { + return claudeCodeInstallationResponse(request); } diff --git a/components/settings/claude-code-status.tsx b/components/settings/claude-code-status.tsx index 274d5b4..cc5fd33 100644 --- a/components/settings/claude-code-status.tsx +++ b/components/settings/claude-code-status.tsx @@ -22,7 +22,7 @@ export function ClaudeCodeInstallationStatus() { request.current = controller; setBusy(true); try { - const response = await fetch("/api/claude-code", { cache: "no-store", signal: controller.signal }); + const response = await fetch("/api/claude-code", { cache: "no-store", signal: controller.signal, headers: { "x-learnharness-claude-code": "1" } }); if (!response.ok) throw new Error("status unavailable"); const value = await response.json() as ClaudeCodeStatus; if (!["not-found", "meets-minimum", "update-required", "version-unchecked"].includes(value.state) diff --git a/src/agent/claude-code/binary.ts b/src/agent/claude-code/binary.ts index 13e86e6..1ee1871 100644 --- a/src/agent/claude-code/binary.ts +++ b/src/agent/claude-code/binary.ts @@ -6,6 +6,7 @@ type BinaryLookup = { env: Record; platform: NodeJS.Platform; homeDir: string; + cwd?: string; isExecutable: (path: string) => boolean; }; @@ -51,7 +52,7 @@ function getPosixSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSp }; } -export function resolveClaudeBinary({ env, platform, homeDir, isExecutable }: BinaryLookup): string | null { +export function resolveClaudeBinary({ env, platform, homeDir, cwd = process.cwd(), isExecutable }: BinaryLookup): string | null { const { paths, names, knownDirs } = platform === "win32" ? getWindowsSearchSpec(env, homeDir) : getPosixSearchSpec(env, homeDir); @@ -60,7 +61,9 @@ export function resolveClaudeBinary({ env, platform, homeDir, isExecutable }: Bi for (const name of names) { for (const dir of [...pathDirs, ...knownDirs]) { - const candidate = paths.join(dir, name); + // Discovery is relative to the server cwd; probes/model work use + // different directories and must receive the same absolute identity. + const candidate = paths.resolve(cwd, dir, name); if (isExecutable(candidate)) return candidate; } } diff --git a/src/server/claude-code.ts b/src/server/claude-code.ts index be52c06..bd40ebe 100644 --- a/src/server/claude-code.ts +++ b/src/server/claude-code.ts @@ -1,8 +1,27 @@ import { inspectClaudeInstallation } from "../agent/claude-code/installation.ts"; import { initAuthEnv } from "./keys.ts"; +import { subscriptionRequestOrigin } from "./web-subscription-http.ts"; /** The Settings status seam: initialize provenance before any child env is copied. */ export async function claudeCodeInstallationStatus() { initAuthEnv(); return inspectClaudeInstallation(); } + +/** Same-origin browser intent, not user authentication. No CORS grants. + * A public/shared server still requires its own access-control boundary. */ +export async function claudeCodeInstallationResponse( + request: Request, + inspect = claudeCodeInstallationStatus, +): Promise { + const origin = subscriptionRequestOrigin(request); + const supplied = request.headers.get("origin"); + const headers = { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }; + if (request.method !== "GET" || !origin + || request.headers.get("x-learnharness-claude-code") !== "1" + || request.headers.get("sec-fetch-site") === "cross-site" + || (supplied !== null && supplied !== origin)) { + return Response.json({ error: "Claude Code checks require a same-origin LearnHarness request." }, { status: 403, headers }); + } + return Response.json(await inspect(), { headers }); +} diff --git a/src/server/index.ts b/src/server/index.ts index cd65868..0c3b002 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -45,4 +45,4 @@ export * from "./observability.ts"; export * from "./model-catalog.ts"; export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts"; export { sessionCost } from "./pricing.ts"; -export { claudeCodeInstallationStatus } from "./claude-code.ts"; +export { claudeCodeInstallationResponse } from "./claude-code.ts"; diff --git a/tests/claude-code-binary.test.ts b/tests/claude-code-binary.test.ts index 599f5d9..ae9c855 100644 --- a/tests/claude-code-binary.test.ts +++ b/tests/claude-code-binary.test.ts @@ -59,6 +59,12 @@ test("windows prefers claude.exe anywhere over claude.cmd, which Node cannot spa assert.equal(resolveWith(executables, env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`); }); +test("relative Windows PATH entries are fixed to an absolute discovery identity", () => { + assert.equal(resolveClaudeBinary({ env: { PATH: ".\\bin" }, platform: "win32", homeDir: "C:\\Users\\u", cwd: "C:\\server", + isExecutable: (candidate) => candidate === "C:\\server\\bin\\claude.exe", + }), "C:\\server\\bin\\claude.exe"); +}); + test("returns null when every location misses", () => { assert.equal(resolveWith([], { PATH: "/usr/bin:/bin" }), null); }); diff --git a/tests/claude-code-status-http.test.ts b/tests/claude-code-status-http.test.ts new file mode 100644 index 0000000..1c8f746 --- /dev/null +++ b/tests/claude-code-status-http.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { claudeCodeInstallationResponse } from "../src/server/claude-code.ts"; + +function request(headers: Record = {}) { + return new Request("http://localhost:3000/api/claude-code", { headers: { "x-learnharness-claude-code": "1", ...headers } }); +} +const status = { state: "not-found" as const, version: null, minimumVersion: "2.1.259" }; + +for (const headers of [ + { origin: "https://attacker.invalid" }, + { "sec-fetch-site": "cross-site" }, + { origin: "http://localhost:3000", "sec-fetch-site": "cross-site" }, + { origin: "null" }, + { "x-learnharness-claude-code": "" }, +] as Record[]) test(`unsafe status request ${JSON.stringify(headers)} never invokes the probe`, async () => { + let probes = 0; + const response = await claudeCodeInstallationResponse(request(headers), async () => { probes++; return status; }); + assert.equal(response.status, 403); assert.equal(probes, 0); + assert.equal(response.headers.get("access-control-allow-origin"), null); +}); + +test("ordinary image/subresource GET without intent header cannot start a probe", async () => { + const response = await claudeCodeInstallationResponse(new Request("http://localhost:3000/api/claude-code"), async () => assert.fail("must not probe")); + assert.equal(response.status, 403); +}); + +for (const headers of [{}, { origin: "http://localhost:3000", "sec-fetch-site": "same-origin" }] as Record[]) test(`legitimate Providers GET ${JSON.stringify(headers)} is supported`, async () => { + let probes = 0; + const response = await claudeCodeInstallationResponse(request(headers), async () => { probes++; return status; }); + assert.equal(response.status, 200); assert.equal(probes, 1); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.deepEqual(await response.json(), status); +}); + +test("a forged forwarded origin cannot authorize a foreign Origin header", async () => { + const response = await claudeCodeInstallationResponse(request({ origin: "https://attacker.invalid", "x-forwarded-host": "attacker.invalid", "x-forwarded-proto": "https" }), async () => assert.fail("must not probe")); + assert.equal(response.status, 403); +}); diff --git a/tests/claude-code-status-ui.test.tsx b/tests/claude-code-status-ui.test.tsx index 5c09ddb..015fe66 100644 --- a/tests/claude-code-status-ui.test.tsx +++ b/tests/claude-code-status-ui.test.tsx @@ -47,6 +47,7 @@ test("mount checks once, Check again refreshes, and neither request asks for aut globalThis.fetch = (async (url, init) => { calls.push(String(url)); assert.equal(init?.cache, "no-store"); + assert.equal(new Headers(init?.headers).get("x-learnharness-claude-code"), "1"); return Response.json(calls.length === 1 ? status("not-found") : status("meets-minimum", "2.1.310")); }) as typeof fetch; await mount(); diff --git a/tests/claude-code-version-runner.test.ts b/tests/claude-code-version-runner.test.ts new file mode 100644 index 0000000..6faafee --- /dev/null +++ b/tests/claude-code-version-runner.test.ts @@ -0,0 +1,63 @@ +/** Real execFile boundary, authored fixture executables only. Never the installed CLI. */ +import assert from "node:assert/strict"; +import { type spawn } from "node:child_process"; +import { accessSync, constants, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test, type TestContext } from "node:test"; +import { resolveClaudeBinary } from "../src/agent/claude-code/binary.ts"; +import { readClaudeVersion } from "../src/agent/claude-code/installation.ts"; +import { createClaudeLauncher } from "../src/agent/claude-code/launch.ts"; + +const unsupportedShebang = process.platform === "win32" || /\s/.test(process.execPath); +function fixture(t: TestContext, mode: "normal" | "oversize" | "hang", name = "claude") { + const dir = mkdtempSync(join(tmpdir(), "lh-version-runner-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const bin = join(dir, "bin"); mkdirSync(bin); + const binary = join(bin, name); + const body = mode === "hang" ? ` +import { writeFileSync } from "node:fs"; +writeFileSync(process.argv[1] + ".pid", String(process.pid)); +process.on("SIGTERM", () => {}); +setInterval(() => {}, 1000); +` : mode === "oversize" ? 'process.stdout.write("x".repeat(100000));' : ` +if (JSON.stringify(process.argv.slice(2)) !== '["--version"]') process.exit(2); +process.stdout.write("2.1.310 (Claude Code)\\n"); +`; + writeFileSync(binary, `#!${process.execPath}\n${body}`, { mode: 0o755 }); + return { dir, binary }; +} + +test("production version runner directly executes a spaced/metacharacter filename with only --version", { skip: unsupportedShebang }, async (t) => { + const { binary } = fixture(t, "normal", "claude ; fixture"); + assert.equal(await readClaudeVersion(binary), "2.1.310 (Claude Code)\n"); +}); + +test("production version runner bounds stdout", { skip: unsupportedShebang }, async (t) => { + const { binary } = fixture(t, "oversize"); + await assert.rejects(readClaudeVersion(binary), (error: NodeJS.ErrnoException) => error.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"); +}); + +test("production version runner times out and SIGKILLs an uncooperative fixture", { skip: unsupportedShebang, timeout: 10000 }, async (t) => { + const { binary } = fixture(t, "hang"); + const start = Date.now(); + await assert.rejects(readClaudeVersion(binary), (error: Error & { signal?: string }) => error.signal === "SIGKILL"); + assert(Date.now() - start < 8500, "bounded five-second timeout"); + const pid = Number(readFileSync(`${binary}.pid`, "utf8")); + assert.throws(() => process.kill(pid, 0), /ESRCH/, "fixture no longer exists"); +}); + +test("relative PATH is resolved at server cwd and stays the same for probe and model launch", { skip: unsupportedShebang }, async (t) => { + const { dir, binary } = fixture(t, "normal"); + const found = resolveClaudeBinary({ env: { PATH: "./bin" }, homeDir: dir, platform: process.platform, cwd: dir, + isExecutable: (path) => { try { accessSync(path, constants.X_OK); return statSync(path).isFile(); } catch { return false; } }, + }); + assert.equal(found, binary); + assert.equal(await readClaudeVersion(found!), "2.1.310 (Claude Code)\n", "probe changes cwd to tmpdir but executes the discovered file"); + let spawned = false; + const launch = createClaudeLauncher({ findBinary: () => found, + spawnProcess: ((path: string) => { assert.equal(path, binary); spawned = true; return null; }) as unknown as typeof spawn, + }); + await launch(["-p"], { cwd: tmpdir(), env: { NODE_ENV: "test" } }); + assert(spawned, "same verified absolute identity passed to the model launcher"); +}); From 1750dd8a2454cba8deed748edd109ff8be2b178c Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 19:52:07 +0200 Subject: [PATCH 73/87] fix: keep successful file path attachments silent --- components/file-path-drop-zone.tsx | 2 +- scripts/smoke-file-paths.mjs | 3 ++- tests/file-path-drop-zone.test.tsx | 12 +++++++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/components/file-path-drop-zone.tsx b/components/file-path-drop-zone.tsx index cc2f292..9408699 100644 --- a/components/file-path-drop-zone.tsx +++ b/components/file-path-drop-zone.tsx @@ -85,7 +85,7 @@ export function FilePathDropZone({ children, className }: { children: React.Reac setNotice("Open a chat to attach file paths."); return; } - setNotice(skipped ? "File paths attached. Some dropped items had no available file path." : "File paths attached."); + setNotice(skipped ? "Some dropped items had no available file path." : ""); }} > {children} diff --git a/scripts/smoke-file-paths.mjs b/scripts/smoke-file-paths.mjs index 59037ce..13d6891 100644 --- a/scripts/smoke-file-paths.mjs +++ b/scripts/smoke-file-paths.mjs @@ -356,7 +356,7 @@ try { await blur(page); await syntheticDrop(page, ["/synthetic/collapsed-practice.pdf"]); // Practice keeps the owning hook/state mounted, but hides its field // by returning the bar. Inspect the controlled text after expansion. - await page.waitForFunction(() => document.querySelector('[role="status"]')?.textContent.includes("File paths attached")); + assert.equal(await page.$('[role="status"]'), null, "successful attachment has no confirmation bubble"); await page.locator('[data-testid="practice-dock-bar"]').click(); await page.waitForSelector(practice, { visible: true }); await expectValue(page, "`/synthetic/practice.pdf` `/synthetic/collapsed-practice.pdf`", practice); @@ -405,6 +405,7 @@ try { await expectValue(desktopPage, `read ${expectedQuoted} this`); assert.equal(await desktopPage.$eval('[data-file-path-input] [role="img"]', chip => chip.title), fixturePath); assert.equal(posts.length, beforeDesktopDrop, "actual OS-backed desktop drop must stage only"); + assert.equal(await desktopPage.$('[role="status"]'), null, "successful desktop attachment has no confirmation bubble"); assert.ok(fs.existsSync(fixturePath)); assert.deepEqual(fs.readdirSync(path.join(home, "data")).filter(name => /upload|attachment/i.test(name)), []); checks.push("Actual Electron production preload: Puppeteer-selected OS-backed File resolves exact Unicode/backtick full path; virtual File returns empty; real DataTransfer drop inserts at caret without submit or file reads"); diff --git a/tests/file-path-drop-zone.test.tsx b/tests/file-path-drop-zone.test.tsx index 8d62a24..17edaa7 100644 --- a/tests/file-path-drop-zone.test.tsx +++ b/tests/file-path-drop-zone.test.tsx @@ -124,7 +124,17 @@ test("desktop drop inserts quoted paths at the focused editor caret instead of t assert.equal(f.doc.activeElement, f.editor); assert.deepEqual(f.appends, [], "focused editor wins over the registered question field"); assert.deepEqual(f.resolved, files, "only Electron's full path, never File.name, enters the draft"); - assert.match(f.status, /File paths attached/); + assert.equal(f.status, "", "successful file attachment is silent"); + f.assertPathOnly(); +}); + +test("a successful drop clears an earlier failure without replacing it with a confirmation bubble", async () => { + const f = await fixture(); + await f.event("drop", f.transfer([f.file("virtual.pdf")])); + assert.match(f.status, /No local file paths were available/); + await f.event("drop", f.transfer([f.file("valid.pdf", "/existing/valid.pdf")])); + assert.deepEqual(f.appends, ["`/existing/valid.pdf`"]); + assert.equal(f.doc.querySelector('[role="status"]'), null); f.assertPathOnly(); }); From 17c94be32791019bf05cdb7b92f345581a7ed673 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 19:58:07 +0200 Subject: [PATCH 74/87] fix: show native Claude CLI usage as unpriced --- components/settings/model-menu.tsx | 7 ++--- src/agent/claude-code/constants.ts | 2 +- src/agent/claude-code/provider.ts | 4 +-- src/core/contracts/claude-code.ts | 2 ++ src/core/contracts/models.ts | 3 ++- src/core/index.ts | 2 +- src/server/model-catalog.ts | 2 +- src/server/pricing.ts | 5 +++- tests/claude-code-usage.test.tsx | 42 +++++++++++++++++++++++++++++ tests/model-catalog.test.ts | 4 ++- tests/pricing-native-parity.test.ts | 4 +-- 11 files changed, 64 insertions(+), 13 deletions(-) create mode 100644 tests/claude-code-usage.test.tsx diff --git a/components/settings/model-menu.tsx b/components/settings/model-menu.tsx index 792ae23..4939672 100644 --- a/components/settings/model-menu.tsx +++ b/components/settings/model-menu.tsx @@ -21,11 +21,12 @@ import { useEffect, useRef, useState } from "react"; import type { ModelCatalogEntry } from "@/src/core"; -import { fuzzyScore, subscriptionLabel } from "@/src/core"; +import { CLAUDE_CODE_PROVIDER, fuzzyScore, subscriptionLabel } from "@/src/core"; import { formatTokens } from "@/components/usage-format"; import { SearchMenuList } from "./search-menu-list"; function priceLabel(cost: ModelCatalogEntry["cost"]): string { + if (!cost) return "Unpriced"; const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`; return `${fmt(cost.input)}/${fmt(cost.output)}`; } @@ -72,8 +73,8 @@ export function ModelMenuList({ renderItem={(m) => <> {m.name} - - {subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)} + + {m.provider === CLAUDE_CODE_PROVIDER ? "CLI billing" : subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)} diff --git a/src/agent/claude-code/constants.ts b/src/agent/claude-code/constants.ts index 18ac8d7..488f3f0 100644 --- a/src/agent/claude-code/constants.ts +++ b/src/agent/claude-code/constants.ts @@ -1,4 +1,4 @@ -export const CLAUDE_CODE_PROVIDER = "claude-code"; +export { CLAUDE_CODE_PROVIDER } from "../../core/contracts/claude-code.ts"; export const CLAUDE_CODE_API = "claude-code-cli"; diff --git a/src/agent/claude-code/provider.ts b/src/agent/claude-code/provider.ts index aca199b..0165e04 100644 --- a/src/agent/claude-code/provider.ts +++ b/src/agent/claude-code/provider.ts @@ -7,8 +7,8 @@ const SHARED_MODEL_FIELDS = { provider: CLAUDE_CODE_PROVIDER, baseUrl: "", input: ["text", "image"], - // Placeholder rates, not a billing guarantee: the CLI's own auth/routing - // determines charges. Cost presentation remains a separate product decision. + // Pi-required placeholders, not prices: the CLI's own auth/routing + // determines charges. Server pricing and the client catalog report unpriced. cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, reasoning: true, // The CLI has no thinking-off switch; effort low is the floor. diff --git a/src/core/contracts/claude-code.ts b/src/core/contracts/claude-code.ts index 50abd27..83d8873 100644 --- a/src/core/contracts/claude-code.ts +++ b/src/core/contracts/claude-code.ts @@ -1,3 +1,5 @@ +export const CLAUDE_CODE_PROVIDER = "claude-code"; + /** Installation/version only. Never an authentication, quota, or billing verdict. */ export interface ClaudeCodeStatus { state: "not-found" | "meets-minimum" | "update-required" | "version-unchecked"; diff --git a/src/core/contracts/models.ts b/src/core/contracts/models.ts index 2819bb5..cab7de7 100644 --- a/src/core/contracts/models.ts +++ b/src/core/contracts/models.ts @@ -21,7 +21,8 @@ export interface ModelCatalogEntry { * thinkingLevelMap — e.g. GLM-5.3-flash: low|high|max). The thinking * picker lists exactly these; anything else is a lie. */ supportedLevels: ThinkingLevel[]; - cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; + /** Null when LearnHarness cannot estimate prices for this connection. */ + cost: { input: number; output: number; cacheRead: number; cacheWrite: number } | null; contextWindow: number; maxTokens: number; } diff --git a/src/core/index.ts b/src/core/index.ts index 4ca5f03..fbdbd9e 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -54,7 +54,7 @@ export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts export { NATIVE_SUBSCRIPTIONS } from "./contracts/subscriptions.ts"; export type { NativeSubscriptionProviderId, NativeSubscriptionLogicalId, NativeSubscriptionStatus, NativeSubscriptionSnapshot, NativeSubscriptionScope, NativeSubscriptionControlBridge, SubscriptionInteraction, SubscriptionPrompt } from "./contracts/subscriptions.ts"; export * from "./cost-summary.ts"; -export { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "./contracts/claude-code.ts"; +export { CLAUDE_CODE_PROVIDER, CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "./contracts/claude-code.ts"; export * from "./fuzzy.ts"; // settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19): // the user-settings record's shape/sanitizer (pure) and its fully-resolved diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index d2d7e91..11063cd 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -164,7 +164,7 @@ export function toModelDto(model: Model): ModelDto { reasoning: model.reasoning, input: [...model.input], supportedLevels: getSupportedThinkingLevels(model), - cost: { + cost: model.provider === CLAUDE_CODE_PROVIDER ? null : { input: model.cost.input, output: model.cost.output, cacheRead: model.cost.cacheRead, diff --git a/src/server/pricing.ts b/src/server/pricing.ts index eaec42d..1454c11 100644 --- a/src/server/pricing.ts +++ b/src/server/pricing.ts @@ -4,12 +4,15 @@ */ import { calculateCost, type Usage } from "@earendil-works/pi-ai"; import { piModels } from "../agent/models.ts"; +import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts"; import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "../core/contracts.ts"; import { isSubscriptionModel, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts"; function catalogModel(id: string) { const colon = id.indexOf(":"); - if (colon < 0 || isSubscriptionModel(id)) return undefined; + // Native CLI routing determines billing. Adapter zero-rate placeholders + // must never become an apparent $0 estimate or subscription classification. + if (colon < 0 || isSubscriptionModel(id) || id.slice(0, colon) === CLAUDE_CODE_PROVIDER) return undefined; return piModels().getModel(id.slice(0, colon), id.slice(colon + 1)); } diff --git a/tests/claude-code-usage.test.tsx b/tests/claude-code-usage.test.tsx new file mode 100644 index 0000000..7c781fc --- /dev/null +++ b/tests/claude-code-usage.test.tsx @@ -0,0 +1,42 @@ +import React from "react"; +(globalThis as Record).React = React; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; +import { isSubscriptionModel, sumSteps, type TurnUsage } from "../src/core/index.ts"; +import { graphCostSummary } from "../src/core/cost-summary.ts"; +import { accumulateJobUsage } from "../src/core/contracts/jobs.ts"; +import { callCost, jobCost, sessionCost } from "../src/server/pricing.ts"; +import { toModelDto } from "../src/server/model-catalog.ts"; +import { ModelMenuList } from "../components/settings/model-menu.tsx"; +import { StatsBar } from "../components/stats-bar.tsx"; + +const step = { inputTokens: 1000, outputTokens: 100, cacheReadTokens: 50, cacheWriteTokens: 0, reasoningTokens: undefined }; +for (const model of CLAUDE_CODE_MODELS) test(`${model.id}: token usage is retained but dollars are unpriced, not free or subscription use`, () => { + const id = `${model.provider}:${model.id}`; + const turn: TurnUsage = { id: "cli-turn", messageId: "cli-message", at: "2026-10-28T00:00:00Z", model: id, steps: [step] }; + assert(!isSubscriptionModel(id)); + assert.equal(callCost(step, id), null); + const pricing = sessionCost({ turns: [turn] }); + assert.equal(pricing.unpriced, 1); assert.equal(pricing.subscription, undefined); + assert.equal(pricing.turnCosts[turn.id], null); + assert.deepEqual(sumSteps(turn.steps), { ...step, cacheWrite1hTokens: undefined, reasoningTokens: undefined }); + assert.equal(jobCost({ usage: accumulateJobUsage(undefined, id, step) }), null); + const html = renderToStaticMarkup(); + assert(!html.includes("$0")); + const summary = graphCostSummary({ chats: [{ title: "CLI fixture", extractions: [{ startedAt: 1, at: turn.at, durationMs: 1, + stages: { extractor: { model: id, steps: [step] }, auditor: { model: id, steps: [step] } }, + }] }], sources: [] }, callCost); + assert.equal(summary.unpricedRuns, 1); assert.equal(summary.subscriptionRuns, undefined); + assert.equal(summary.runs[0]!.dollars, null); +}); + +test("model-catalog API and menus never advertise the CLI adapter's placeholder zero rates", () => { + const models = CLAUDE_CODE_MODELS.map(toModelDto); + assert(models.every((model) => model.cost === null)); + const html = renderToStaticMarkup( {}} />); + assert.equal((html.match(/>CLI billing<\/span>/g) ?? []).length, 3); + assert.match(html, /does not estimate it/); + assert(!html.includes("$0")); assert(!html.includes("USD per 1M")); assert(!html.includes("(subscription)")); +}); diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index 62b14fc..3ab0ea6 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -64,7 +64,8 @@ describe("the model catalog projection", () => { assert.equal(typeof m.id, "string"); assert.equal(typeof m.name, "string"); assert.ok(Array.isArray(m.supportedLevels)); - assert.ok(typeof m.cost.input === "number" && typeof m.cost.output === "number"); + if (m.provider === "claude-code") assert.equal(m.cost, null); + else assert.ok(m.cost && typeof m.cost.input === "number" && typeof m.cost.output === "number"); assert.ok(m.contextWindow > 0); } const glm = models.find((m) => m.id === "z-ai/glm-5.3-flash"); @@ -126,6 +127,7 @@ describe("the model catalog projection", () => { assert.equal(dto.id, raw.id); assert.equal(dto.name, raw.name); assert.deepEqual(dto.supportedLevels, getSupportedThinkingLevels(raw)); + assert.ok(dto.cost); assert.equal(dto.cost.input, raw.cost.input); assert.equal(dto.contextWindow, raw.contextWindow); }); diff --git a/tests/pricing-native-parity.test.ts b/tests/pricing-native-parity.test.ts index 80056bc..5f47411 100644 --- a/tests/pricing-native-parity.test.ts +++ b/tests/pricing-native-parity.test.ts @@ -10,7 +10,7 @@ const models = piModels().getProviders().flatMap((p) => piModels().getModels(p.i const nativeUsage = (input: number): Usage => ({ input, output: 1_000, cacheRead: 10_000, cacheWrite: 2_000, cacheWrite1h: 500, totalTokens: input + 13_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }); const saved = (u: Usage) => ({ inputTokens: u.input, outputTokens: u.output, cacheReadTokens: u.cacheRead, cacheWriteTokens: u.cacheWrite, cacheWrite1hTokens: u.cacheWrite1h, reasoningTokens: 500 }); -test("all catalog models price through Pi, including every tier boundary; saved usage is never mutated", () => { +test("API catalog models price through Pi at every tier boundary; native CLI stays unpriced and saved usage is never mutated", () => { assert.ok(models.length > 100); for (const model of models) { const sizes = [1_000, ...(model.cost.tiers ?? []).flatMap((t) => [Math.max(0, t.inputTokensAbove - 12_000), Math.max(0, t.inputTokensAbove - 12_000 + 1)])]; @@ -18,7 +18,7 @@ test("all catalog models price through Pi, including every tier boundary; saved const usage = nativeUsage(input); const call = Object.freeze(saved(usage)); const before = structuredClone(call); - const expected = calculateCost(model, usage).total; + const expected = model.provider === "claude-code" ? null : calculateCost(model, usage).total; assert.equal(callCost(call, `${model.provider}:${model.id}`), expected, `${model.provider}:${model.id} input=${input}`); assert.deepEqual(call, before); } From 074ba7afe4bc7c2e4f20cc86d2031455fc0a4369 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 20:36:42 +0200 Subject: [PATCH 75/87] feat: use session-reported Claude CLI context windows --- components/chat.tsx | 5 ++ components/model-picker.tsx | 13 ++- components/settings/model-menu.tsx | 2 +- components/stats-bar.tsx | 27 ++++-- src/agent/claude-code/context-window.ts | 71 ++++++++++++++++ src/agent/claude-code/stream.ts | 24 +++++- src/core/contracts/models.ts | 5 +- src/core/native-messages.ts | 26 +++++- src/server/model-catalog.ts | 11 +-- tests/claude-code-context-ui.test.tsx | 43 ++++++++++ tests/claude-code-context-window.test.ts | 98 ++++++++++++++++++++++ tests/claude-code-harness.test.ts | 6 +- tests/claude-code-stream.test.ts | 31 ++++++- tests/fixtures/claude-code/fake-claude.mjs | 26 +++++- tests/model-catalog.test.ts | 7 +- 15 files changed, 359 insertions(+), 36 deletions(-) create mode 100644 src/agent/claude-code/context-window.ts create mode 100644 tests/claude-code-context-ui.test.tsx create mode 100644 tests/claude-code-context-window.test.ts diff --git a/components/chat.tsx b/components/chat.tsx index 2d851d1..c1b97c8 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -37,6 +37,7 @@ import { useServerEvent, useServerResync } from "@/components/server-events"; import { isProviderKeyRefusal, firstSourceTextParts, + cliContextWindowOf, messageAnchorId, textPartMessageId, kbdEventForAction as kbdEventForChat, @@ -1690,6 +1691,7 @@ export function Chat({ phase={phases.at(-1) ?? "probe"} turns={turns} contextTokens={contextTokens} + cliContextWindow={cliContextWindowOf(nativeMessages)} pricing={pricing} onReserve={setDockReserve} dictationSlot={} @@ -1916,6 +1918,7 @@ function DockOverlay({ phase, turns, contextTokens, + cliContextWindow, pricing, composer, onSubmitAnswer, @@ -1952,6 +1955,7 @@ function DockOverlay({ /** The session's usage log — the stats bar beneath the composer folds it. */ turns: TurnUsage[]; contextTokens: number | null; + cliContextWindow: ReturnType; pricing?: SessionPricing; composer: React.ReactNode; onSubmitAnswer: (output: QuestionAnswerData) => void; @@ -2407,6 +2411,7 @@ function DockOverlay({ ): Map { - return new Map(catalog.models.map((m) => [`${m.provider}:${m.id}`, m.contextWindow])); + return new Map(catalog.models.flatMap((m) => m.contextWindow === null ? [] : [[`${m.provider}:${m.id}`, m.contextWindow] as const])); } /** diff --git a/components/settings/model-menu.tsx b/components/settings/model-menu.tsx index 4939672..678ba09 100644 --- a/components/settings/model-menu.tsx +++ b/components/settings/model-menu.tsx @@ -79,7 +79,7 @@ export function ModelMenuList({ {subscriptionLabel(`${m.provider}:${m.id}`) ? `${subscriptionLabel(`${m.provider}:${m.id}`)} (subscription)` : m.provider} · {m.id} - {formatTokens(m.contextWindow)} + {m.contextWindow === null ? "—" : formatTokens(m.contextWindow)} } /> diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx index 6c1c05d..1abbc2b 100644 --- a/components/stats-bar.tsx +++ b/components/stats-bar.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { subscriptionLabels, subscriptionLabel, type SessionPricing, type TurnUsage } from "@/src/core"; +import { CLAUDE_CODE_PROVIDER, subscriptionLabels, subscriptionLabel, type cliContextWindowOf, type SessionPricing, type TurnUsage } from "@/src/core"; import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format"; import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker"; @@ -10,6 +10,15 @@ export function formatContextMeasurement(tokens: number | null, limit: number | return limit === null ? numerator : `${numerator} / ${formatTokens(limit)}`; } +/** CLI aliases have no global catalog limit: only this session's matching + * response can supply a denominator. Other providers keep their catalog join. */ +export function contextLimitForModel(model: string | undefined, limits: ReadonlyMap | undefined, + cliWindow: ReturnType = null): number | null { + if (!model) return null; + if (model.startsWith(`${CLAUDE_CODE_PROVIDER}:`)) return cliWindow?.model === model ? cliWindow.tokens : null; + return limits?.get(model) ?? null; +} + /** * The below-composer stats bar (wave 4 observability): the session's * money/context footprint as one quiet row beneath the composer — @@ -24,12 +33,14 @@ export function formatContextMeasurement(tokens: number | null, limit: number | * Spending arrives already priced by the server through Pi's catalog and * calculator. Missing cost stays "—", never fake zero. Context is a separate * completed-conversation measurement, never a money-log/summarizer fold or - * transcript estimate. Its catalog denominator follows the selected model; - * model changes preserve the numerator. Successful compaction clears it to ?. + * transcript estimate. Its denominator follows the selected model, from the + * catalog or the session's CLI report; model changes preserve the numerator. + * Successful compaction clears the numerator and any old CLI report. */ export function StatsBar({ turns, contextTokens = null, + cliContextWindow = null, pricing, dictationSlot, chatId, @@ -38,6 +49,7 @@ export function StatsBar({ }: { turns: TurnUsage[]; contextTokens?: number | null; + cliContextWindow?: ReturnType; pricing?: SessionPricing; /** The bound session (per-session models, owner 2026-09-28): present * on the in-session bar — the model control binds THE SESSION'S own @@ -86,11 +98,11 @@ export function StatsBar({ document.removeEventListener("keydown", onKeyDown); }; }, [open]); - // Catalog-only denominator; the picker reports its effective selection. - // Missing/pending catalog means usage-only, never a hardcoded maximum. + // The picker reports its effective selection. CLI windows are session- + // scoped; unknown metadata always means usage-only, never a guessed maximum. const { catalog } = useModelCatalog(); const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]); - const limit = selectedModel ? limits?.get(selectedModel) ?? null : null; + const limit = contextLimitForModel(selectedModel, limits, cliContextWindow); const contextLabel = formatContextMeasurement(contextTokens, limit); const { cost = 0, unpriced = turns.length, subscription = 0 } = pricing ?? {}; // Honest cost: show dollars only when at least one turn priced; @@ -151,6 +163,9 @@ export function StatsBar({ {contextLabel}
+ {selectedModel?.startsWith(`${CLAUDE_CODE_PROVIDER}:`) && ( +

The window is reported by Claude Code’s local summary and may reflect its auto-compaction budget rather than the model’s full maximum. Unavailable windows are omitted.

+ )}
{subscription ? "Usage" : "Total cost"} {costLabel} diff --git a/src/agent/claude-code/context-window.ts b/src/agent/claude-code/context-window.ts new file mode 100644 index 0000000..9dd2354 --- /dev/null +++ b/src/agent/claude-code/context-window.ts @@ -0,0 +1,71 @@ +import { randomUUID } from "node:crypto"; + +const INITIALIZE_TIMEOUT_MS = 5_000; + +/** A best-effort, local-only CLI control query. Wire contract matches the + * official SDK0.3.259 source; this does not import or execute the SDK. + * Summary deliberately avoids the default full mode's token-count requests. + * CLI maxTokens is an effective/auto-compaction budget, not a nominal model max. */ +export class ContextWindowProbe { + private readonly initializeId = randomUUID(); + private summaryId: string | undefined; + private initialized = false; + private timer: NodeJS.Timeout | undefined; + private ready = false; + private closed = false; + + constructor( + private readonly write: (line: string) => void, + private readonly sendPrompt: () => void, + private readonly onWindow: (tokens: number) => void, + ) {} + + start(): void { + this.timer = setTimeout(() => this.proceed(false), INITIALIZE_TIMEOUT_MS); + this.timer.unref(); + this.request(this.initializeId, { subtype: "initialize" }); + } + + /** Consume control replies separately from model/tool output, including + * late/foreign replies while a native question tool is already waiting. */ + handle(value: unknown): boolean { + if (!value || typeof value !== "object" || !("type" in value) || value.type !== "control_response") return false; + if (this.closed || !("response" in value) || !value.response || typeof value.response !== "object") return true; + const response = value.response as Record; + if (response.request_id === this.initializeId) this.proceed(response.subtype === "success"); + else if (this.summaryId && response.request_id === this.summaryId) { + this.summaryId = undefined; + if (response.subtype !== "success") return true; + const body = response.response as Record | undefined; + const tokens = body?.maxTokens; + if (typeof tokens === "number" && Number.isSafeInteger(tokens) && tokens > 0) this.onWindow(tokens); + } + return true; + } + + /** Retry when the CLI has begun a model response: pre-first-turn metadata + * is not guaranteed by the protocol. Refresh also follows later responses + * within the same held process, without opening a second process. */ + refresh(): void { + if (!this.initialized || this.closed) return; + this.summaryId = randomUUID(); + this.request(this.summaryId, { subtype: "get_context_usage", detail: "summary" }); + } + + close(): void { this.closed = true; clearTimeout(this.timer); } + + private proceed(query: boolean): void { + if (this.ready || this.closed) return; + this.ready = true; + clearTimeout(this.timer); + this.initialized = query; + if (query) this.refresh(); + // Never wait for metadata to run the actual user request. No support, + // no response, or malformed metadata simply leaves the window unknown. + this.sendPrompt(); + } + + private request(id: string, request: Record): void { + this.write(JSON.stringify({ type: "control_request", request_id: id, request })); + } +} diff --git a/src/agent/claude-code/stream.ts b/src/agent/claude-code/stream.ts index 39a49ef..ba96098 100644 --- a/src/agent/claude-code/stream.ts +++ b/src/agent/claude-code/stream.ts @@ -24,9 +24,11 @@ import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messag import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse"; import { externalCliEnv } from "../../core/auth-env.ts"; +import type { LhMessage } from "../../core/native-messages.ts"; import { ClaudeLaunchError, createClaudeLauncher } from "./launch.ts"; import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts"; import { ClaudeCodeError } from "./constants.ts"; +import { ContextWindowProbe } from "./context-window.ts"; import { addLiveProcess, getLiveProcess, removeLiveProcess, type LiveProcess } from "./live-processes.ts"; import { configurationForReuse, conversationForReuse } from "./reuse-context.ts"; import { BRIDGE_SERVER_NAME, createToolBridge, toolNameFromMcp, type PendingToolCall, type ToolBridge } from "./tool-bridge.ts"; @@ -137,7 +139,7 @@ function getAssistantErrorMessage(error: string, text: string): string { } class ClaudeTurn { - private readonly output: AssistantMessage; + private readonly output: AssistantMessage & Pick; private readonly contentIndexes = new Map(); private readonly partialJson = new Map(); private readonly toolBlocks = new Map(); @@ -233,6 +235,10 @@ class ClaudeTurn { getMessage(): AssistantMessage { return this.output; } + setContextWindow(tokens: number | undefined): void { + if (!this.finished && tokens !== undefined) this.output.cliContextWindow = tokens; + } + admit(calls: ToolCall[]): void { this.start(); for (const call of calls) { @@ -398,6 +404,8 @@ class ClaudeProcess implements LiveProcess { private admitted: PendingToolCall[] = []; private readonly cancelledIds = new Set(); private pendingError: string | undefined; + private contextWindow: number | undefined; + private contextProbe: ContextWindowProbe | undefined; constructor( private readonly child: ChildProcessWithoutNullStreams, @@ -420,12 +428,17 @@ class ClaudeProcess implements LiveProcess { this.child.stderr.setEncoding("utf8"); this.child.stderr.on("data", (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-65_536); }); this.child.stdin.on("error", (error) => console.error("claude CLI stdin closed early:", error)); - this.child.stdin.end(`${stdinLine}\n`); + this.contextProbe = new ContextWindowProbe( + (line) => this.child.stdin.write(`${line}\n`), + () => { if (!this.stopping) this.child.stdin.write(`${stdinLine}\n`); }, + (tokens) => { this.contextWindow = tokens; this.turn.setContextWindow(tokens); }, + ); createInterface({ input: this.child.stdout }).on("line", (line) => { try { this.handleLine(line); } catch { this.fail("claude CLI emitted malformed stream output"); } }); this.setSignal(signal); + if (!this.stopping) this.contextProbe.start(); } canContinue(owner: symbol, sessionId: string | undefined, configuration: unknown, messages: Message[], results: ToolResultMessage[]): boolean { @@ -445,6 +458,7 @@ class ClaudeProcess implements LiveProcess { this.admitted = []; this.conversation = structuredClone(conversationForReuse(messages)); this.turn = turn; + this.turn.setContextWindow(this.contextWindow); this.sourceComplete = this.sourceCalls.length > 0; this.setSignal(signal); if (this.stopping) return; @@ -459,6 +473,7 @@ class ClaudeProcess implements LiveProcess { removeLiveProcess(this); if (this.stopping || this.closed) return; this.stopping = true; + this.contextProbe?.close(); this.child.kill(); this.killTimer = setTimeout(() => this.child.kill("SIGKILL"), 1000); this.killTimer.unref(); @@ -526,7 +541,8 @@ class ClaudeProcess implements LiveProcess { } private handleLine(line: string): void { - if (this.stopping) return; + if (this.stopping || !line.trim()) return; + if (this.contextProbe?.handle(JSON.parse(line))) return; const event = parseCliLine(line); if (!event) return; if (event.type === "toolResults") { @@ -545,6 +561,7 @@ class ClaudeProcess implements LiveProcess { this.fail("claude CLI moved on without admitting its harness tool calls"); return; } + if (event.type === "messageStart") this.contextProbe?.refresh(); this.turn.handle(event); if (this.turn.finished) { this.kill(); return; } if (event.type === "messageStop" && this.turn.waitingForAdmission) { @@ -562,6 +579,7 @@ class ClaudeProcess implements LiveProcess { private handleClose(message: string): void { if (this.closed) return; this.closed = true; + this.contextProbe?.close(); clearTimeout(this.killTimer); removeLiveProcess(this); if (this.pendingError) addLiveProcess(this); diff --git a/src/core/contracts/models.ts b/src/core/contracts/models.ts index cab7de7..03d3241 100644 --- a/src/core/contracts/models.ts +++ b/src/core/contracts/models.ts @@ -23,8 +23,9 @@ export interface ModelCatalogEntry { supportedLevels: ThinkingLevel[]; /** Null when LearnHarness cannot estimate prices for this connection. */ cost: { input: number; output: number; cacheRead: number; cacheWrite: number } | null; - contextWindow: number; - maxTokens: number; + /** Null for native CLI aliases whose effective limits are session-specific. */ + contextWindow: number | null; + maxTokens: number | null; } /** The conversation agents' resolved default (BYOK, owner 2026-09-22): the diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index c9d5a46..520fa86 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -36,6 +36,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai"; import { QUESTION_TOOLS } from "./pending.ts"; +import { CLAUDE_CODE_PROVIDER } from "./contracts/claude-code.ts"; import { CONCLUDE_ACK_TEXT, HANDOFF_TEXT, @@ -90,7 +91,26 @@ declare module "@earendil-works/pi-agent-core" { } /** A record message: a native pi message (or our marker) plus the record id. */ -export type LhMessage = (UserMessage | AssistantMessage | ToolResultMessage | LhMarkerMessage) & { id: string }; +export type LhMessage = (UserMessage | AssistantMessage | ToolResultMessage | LhMarkerMessage) & { + id: string; + /** CLI-reported effective context budget, not a catalog model maximum. + * App-only metadata: never transmitted as provider/model message content. */ + cliContextWindow?: number; +}; + +/** Latest response only: never borrow an older process/model's window when + * a fresh response has no metadata. Compaction invalidates the old report. */ +export function cliContextWindowOf(messages: readonly LhMessage[]): { model: string; tokens: number } | null { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]!; + if (message.role === "custom" && message.customType === "compaction") return null; + if (message.role !== "assistant") continue; + const tokens = message.cliContextWindow; + return message.provider === CLAUDE_CODE_PROVIDER && typeof tokens === "number" && Number.isSafeInteger(tokens) && tokens > 0 + ? { model: `${message.provider}:${message.model}`, tokens } : null; + } + return null; +} /** A record message list is assignable to the agent's context — that IS * the replay (ui-stream/chat-handler assign it to initialState.messages). */ @@ -617,8 +637,8 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] { }); continue; } - // Strip the record id: the wire carries the native shape only. - const { id: _id, ...native } = m as LhMessage; + // Strip app-only id/CLI context metadata: the wire carries the native shape only. + const { id: _id, cliContextWindow: _cliContextWindow, ...native } = m as LhMessage; out.push(native as Message); // Repair unanswered calls from completed generations, not calls in // failed generations. Pi filters error/aborted assistants itself; diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index 11063cd..6605788 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -170,15 +170,16 @@ export function toModelDto(model: Model): ModelDto { cacheRead: model.cost.cacheRead, cacheWrite: model.cost.cacheWrite, }, - contextWindow: model.contextWindow, - maxTokens: model.maxTokens, + contextWindow: model.provider === CLAUDE_CODE_PROVIDER ? null : model.contextWindow, + maxTokens: model.provider === CLAUDE_CODE_PROVIDER ? null : model.maxTokens, }; } /** The live-limits index (context-meter amendment, owner 2026-09-23): * provider:id → contextWindow from pi's catalog, over the SAME keyed - * providers the picker federates. The one source for every context - * meter's denominator — the stats-bar join holds /api/models' copy, + * providers the picker federates. Native CLI aliases are excluded: + * only a session-specific CLI report can provide their denominator. + * For other models the stats-bar join holds /api/models' copy, * and the activity feed (observability.ts) resolves job limits * server-side through this. No hardcoded fallback exists anywhere: * a model missing here reads limit-null (usage-only, honest render). */ @@ -186,7 +187,7 @@ export function catalogContextLimits(): Map { const keyed = keyedProviderIds(); const index = new Map(); for (const provider of piModels().getProviders()) { - if (!keyed.has(provider.id)) continue; + if (!keyed.has(provider.id) || provider.id === CLAUDE_CODE_PROVIDER) continue; for (const model of piModels().getModels(provider.id)) { index.set(`${provider.id}:${model.id}`, model.contextWindow); } diff --git a/tests/claude-code-context-ui.test.tsx b/tests/claude-code-context-ui.test.tsx new file mode 100644 index 0000000..3b026ac --- /dev/null +++ b/tests/claude-code-context-ui.test.tsx @@ -0,0 +1,43 @@ +import React, { act } from "react"; +(globalThis as Record).React = React; +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { createRoot, type Root } from "react-dom/client"; +import { parseHTML } from "linkedom"; +import { StatsBar } from "../components/stats-bar.tsx"; +import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; +import { toModelDto } from "../src/server/model-catalog.ts"; +import type { ModelCatalogResponse } from "../src/core/index.ts"; + +const dom = parseHTML(""); +Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, + CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true }); +const originalFetch = globalThis.fetch; +let root: Root | undefined, host: HTMLDivElement | undefined; +afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; globalThis.fetch = originalFetch; }); + +test("compact CLI context is numeric only; effective-window explanation appears only in expanded session metadata", async () => { + const catalog: ModelCatalogResponse = { models: CLAUDE_CODE_MODELS.map(toModelDto), providers: [], + slots: { strong: null, cheap: null, vision: null }, defaults: null, + rolePins: { researcher: null, demoBuilder: null, visualMaker: null, extractor: null, auditor: null } }; + globalThis.fetch = (async (url) => { + if (url === "/api/models") return Response.json(catalog); + if (url === "/api/settings") return Response.json({ settings: { models: {} } }); + if (url === "/api/chats/cli-session") return Response.json({ settings: { model: { provider: "claude-code", id: "opus" } } }); + throw new Error(`unexpected request ${url}`); + }) as typeof fetch; + host = document.createElement("div"); document.body.append(host); root = createRoot(host); + const render = (window: { model: string; tokens: number } | null) => root!.render(); + await act(async () => render({ model: "claude-code:opus", tokens: 200000 })); + assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k"); + assert(!document.body.textContent?.includes("auto-compaction")); + const summary = document.querySelector('[data-testid="stats-summary"]')!; + assert(!summary.textContent?.includes("window")); assert(!summary.textContent?.includes("budget")); + await act(async () => summary.click()); + assert.match(document.querySelector('[data-testid="stats-detail"]')?.textContent ?? "", /auto-compaction budget rather than the model’s full maximum/); + assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k"); + await act(async () => render(null)); + assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k"); + await act(async () => render({ model: "claude-code:sonnet", tokens: 1000000 })); + assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k", "another alias cannot supply this session's denominator"); +}); diff --git a/tests/claude-code-context-window.test.ts b/tests/claude-code-context-window.test.ts new file mode 100644 index 0000000..5b131eb --- /dev/null +++ b/tests/claude-code-context-window.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ContextWindowProbe } from "../src/agent/claude-code/context-window.ts"; +import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; +import { asAgentMessages, cliContextWindowOf, toLlmMessages, type LhMessage } from "../src/core/native-messages.ts"; +import { catalogLimits } from "../components/model-picker.tsx"; +import { contextLimitForModel, formatContextMeasurement } from "../components/stats-bar.tsx"; +import { toModelDto } from "../src/server/model-catalog.ts"; + +function setup() { + const writes: { type: string; request_id: string; request: Record }[] = []; + const windows: number[] = []; + let prompts = 0; + const probe = new ContextWindowProbe((line) => writes.push(JSON.parse(line)), () => prompts++, (tokens) => windows.push(tokens)); + probe.start(); + const reply = (index: number, body: unknown, subtype = "success") => probe.handle({ type: "control_response", response: { + request_id: writes[index]!.request_id, subtype, response: body, + } }); + return { probe, writes, windows, reply, prompts: () => prompts }; +} + +test("control initialization precedes a summary-only query; neither needs user input or a model request", () => { + const p = setup(); + assert.deepEqual(p.writes[0]!.request, { subtype: "initialize" }); assert.equal(p.prompts(), 0); + p.reply(0, {}); + assert.deepEqual(p.writes[1]!.request, { subtype: "get_context_usage", detail: "summary" }); + assert.equal(p.prompts(), 1, "conversation does not wait for metadata"); + assert.notEqual(p.writes[0]!.request_id, p.writes[1]!.request_id); + p.probe.handle({ type: "control_response", response: { request_id: "foreign", subtype: "success", response: { maxTokens: 999 } } }); + assert.deepEqual(p.windows, []); + p.reply(1, { maxTokens: 200000, rawMaxTokens: 1000000 }); + assert.deepEqual(p.windows, [200000], "effective maxTokens, not an assumed nominal window"); + p.reply(0, {}); p.reply(1, { maxTokens: 1 }); + assert.equal(p.prompts(), 1); assert.deepEqual(p.windows, [200000]); + p.probe.close(); +}); + +test("metadata unavailable before first response can be queried again locally; stale/closed replies are ignored", () => { + const p = setup(); p.reply(0, {}); p.reply(1, {}, "error"); + assert.equal(p.prompts(), 1); assert.deepEqual(p.windows, []); + p.probe.refresh(); + assert.deepEqual(p.writes[2]!.request, { subtype: "get_context_usage", detail: "summary" }); + p.reply(1, { maxTokens: 1 }); assert.deepEqual(p.windows, []); + p.reply(2, { maxTokens: 200000 }); assert.deepEqual(p.windows, [200000]); + p.probe.refresh(); p.probe.close(); p.reply(3, { maxTokens: 999999 }); + assert.deepEqual(p.windows, [200000]); assert.equal(p.prompts(), 1); +}); + +for (const tokens of [null, undefined, 0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1, "200000"]) test(`invalid summary budget ${tokens} remains unknown without failing the prompt`, () => { + const p = setup(); p.reply(0, {}); p.reply(1, { maxTokens: tokens }); + assert.deepEqual(p.windows, []); assert.equal(p.prompts(), 1); p.probe.close(); +}); + +test("initialization errors/timeouts fall back to ordinary prompt delivery; close cancels the timer", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const error = setup(); error.reply(0, undefined, "error"); + assert.equal(error.prompts(), 1); assert.equal(error.writes.length, 1); error.probe.close(); + const timeout = setup(); t.mock.timers.tick(5000); + assert.equal(timeout.prompts(), 1); assert.equal(timeout.writes.length, 1); + timeout.reply(0, {}); assert.equal(timeout.writes.length, 1); timeout.probe.close(); + const closed = setup(); closed.probe.close(); t.mock.timers.tick(5000); + assert.equal(closed.prompts(), 0); +}); + +function assistant(model = "opus", tokens: number | undefined = 200000): LhMessage & { role: "assistant" } { + return { id: "cli-assistant", role: "assistant", api: "claude-code-cli", provider: "claude-code", model, + content: [], timestamp: 0, stopReason: "stop", cliContextWindow: tokens, + usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } }; +} + +test("per-session metadata survives JSON persistence, never borrows older reports, and cannot override other model selections", () => { + const first = assistant(); + const window = cliContextWindowOf(JSON.parse(JSON.stringify([first]))); + assert.deepEqual(window, { model: "claude-code:opus", tokens: 200000 }); + assert(!("cliContextWindow" in toLlmMessages(asAgentMessages([first]))[0]!)); + assert.equal(first.cliContextWindow, 200000, "provider projection never mutates the stored report"); + const catalog = new Map([["anthropic:fixture", 100000], ["claude-code:opus", 1000000]]); + assert.equal(contextLimitForModel("claude-code:opus", catalog, window), 200000); + assert.equal(contextLimitForModel("claude-code:sonnet", catalog, window), null); + assert.equal(contextLimitForModel("claude-code:opus", catalog, null), null, "no placeholder fallback"); + assert.equal(contextLimitForModel("anthropic:fixture", catalog, window), 100000); + assert.equal(contextLimitForModel(undefined, catalog, window), null); + assert.equal(formatContextMeasurement(42000, 200000), "42k / 200k"); + assert.equal(formatContextMeasurement(42000, null), "42k"); + const unreported = assistant("opus"); delete unreported.cliContextWindow; + assert.equal(cliContextWindowOf([first, unreported]), null); + const other = assistant("api"); other.provider = "anthropic"; + assert.equal(cliContextWindowOf([first, other]), null); + const compacted: LhMessage = { id: "compact", role: "custom", customType: "compaction", content: "summary", display: false, details: {}, timestamp: 1 }; + assert.equal(cliContextWindowOf([first, compacted]), null); + assert.equal(cliContextWindowOf([]), null); +}); + +test("CLI catalog limits remain unknown and cannot create a global denominator", () => { + const models = CLAUDE_CODE_MODELS.map(toModelDto); + assert(models.every((model) => model.contextWindow === null && model.maxTokens === null)); + assert.equal(catalogLimits({ models }).size, 0); +}); diff --git a/tests/claude-code-harness.test.ts b/tests/claude-code-harness.test.ts index 875c96c..78bedb2 100644 --- a/tests/claude-code-harness.test.ts +++ b/tests/claude-code-harness.test.ts @@ -23,7 +23,7 @@ import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; import { createClaudeCodeStreamFunction, type RunClaude } from "../src/agent/claude-code/stream.ts"; import type { PiEngine } from "../src/agent/models.ts"; import { createTeacherAgent } from "../src/agent/teacher-agent.ts"; -import { danglingQuestionOf } from "../src/core/native-messages.ts"; +import { cliContextWindowOf, danglingQuestionOf } from "../src/core/native-messages.ts"; import { handleChatRequest } from "../src/server/chat-handler.ts"; import { isTurnRunning } from "../src/server/turn-liveness.ts"; import { runningTurn } from "../src/server/turn-runs.ts"; @@ -157,6 +157,8 @@ async function waitForExit(child: ChildProcessWithoutNullStreams): Promise test("a question answered before the idle timeout continues the waiting CLI process with the answer", async (t) => { const fakeRun = createFakeRun(t, [toAskScenario()]); + const run = fakeRun.runClaude; + fakeRun.runClaude = (args, options) => run(args, { ...options, env: { ...options.env, FAKE_CLAUDE_WINDOW: "200000" } }); const agentFactory = createAgentFactory(fakeRun); const chatId = createClaudeCodeChat(t); @@ -170,6 +172,8 @@ test("a question answered before the idle timeout continues the waiting CLI proc await runChatTurn(chatId, toAnswerInput(), agentFactory); assert.equal(fakeRun.children.length, 1); + assert.deepEqual(cliContextWindowOf(chatStore.getChat(chatId)!.messages), { model: "claude-code:sonnet", tokens: 200000 }); + assert.deepEqual(cliContextWindowOf(JSON.parse(readFileSync(join(PATHS.chats, `${chatId}.json`), "utf8")).messages), { model: "claude-code:sonnet", tokens: 200000 }); assert.ok(getLastAssistantText(chatId).includes(ANSWER_TEXT), getLastAssistantText(chatId)); await waitForExit(waiting); }); diff --git a/tests/claude-code-stream.test.ts b/tests/claude-code-stream.test.ts index 19e3a76..8da5596 100644 --- a/tests/claude-code-stream.test.ts +++ b/tests/claude-code-stream.test.ts @@ -620,13 +620,18 @@ test("does not inject credentials reflected only from LearnHarness's key store", assert.equal(process.env[name], "lh-only-key", "other LearnHarness providers keep their env reflection"); }); -test("writes the transcript as one stream-json user line on stdin", async (t) => { +test("writes one user prompt after local-only control initialization and summary request", async (t) => { const stdinFile = join(mkdtempSync(join(tmpdir(), "claude-code-stdin-")), "stdin.jsonl"); t.after(() => rmSync(dirname(stdinFile), { recursive: true, force: true })); await runScenario(t, readFixtureLines("text.jsonl"), undefined, { FAKE_CLAUDE_STDIN: stdinFile }); - assert.equal(readFileSync(stdinFile, "utf8"), `${toStreamJsonLine([{ type: "text", text: "hi" }])}\n`); + const lines = readFileSync(stdinFile, "utf8").trim().split("\n"); + assert(lines.length >= 3); + assert.deepEqual(JSON.parse(lines[0]!).request, { subtype: "initialize" }); + assert.deepEqual(JSON.parse(lines[1]!).request, { subtype: "get_context_usage", detail: "summary" }); + assert.equal(lines[2], toStreamJsonLine([{ type: "text", text: "hi" }])); + assert(lines.slice(3).every((line) => JSON.parse(line).request.detail === "summary")); }); test("parseCliLine ignores non-JSON, system, rate-limit and successful result lines", () => { @@ -1002,6 +1007,28 @@ test("a caller without a session ID uses fresh replay and retains no held proces await assertCleanedUp(getOnlyCall(fresh.calls)); }); +test("CLI window metadata stays on its own process and survives a held-tool continuation", async (t) => { + const read: ToolUse = { id: "toolu_window", name: "read", arguments: { path: "one" } }; + const one = createFakeRun(t, [...toToolUseMessage([read]), callTool(read), REPLY], { FAKE_CLAUDE_WINDOW: "200000" }); + const two = createFakeRun(t, [...toToolUseMessage([read]), callTool(read), REPLY], { FAKE_CLAUDE_WINDOW: "750000" }); + let spawns = 0; + const stream = createClaudeCodeStreamFunction((args, opts) => (++spawns === 1 ? one : two).runClaude(args, opts)); + const window = (m: AssistantMessage) => (m as AssistantMessage & { cliContextWindow?: number }).cliContextWindow; + const a = await runTurn(stream, [HI], { sessionId: "context-one" }); + const b = await runTurn(stream, [HI], { sessionId: "context-two" }); + assert.equal(window(a), 200000); assert.equal(window(b), 750000); + const ra = await runTurn(stream, [HI, a, toToolResult(read, "a")], { sessionId: "context-one" }); + const rb = await runTurn(stream, [HI, b, toToolResult(read, "b")], { sessionId: "context-two" }); + assert.equal(window(ra), 200000); assert.equal(window(rb), 750000); assert.equal(spawns, 2); +}); + +for (const extraEnv of [{ FAKE_CLAUDE_INIT: "error" }, { FAKE_CLAUDE_WINDOW: "-1" }, {}] as Record[]) test(`missing/unsupported CLI window never prevents a normal response: ${JSON.stringify(extraEnv)}`, async (t) => { + const { events } = await runScenario(t, readFixtureLines("text.jsonl"), undefined, extraEnv); + const result = getLastEvent(events); + assert.equal(result.type, "done"); + assert(!("cliContextWindow" in (result.type === "done" ? result.message : {}))); +}); + for (const sharedIdentity of [false, true]) test(`two concurrent stream owners keep separate processes${sharedIdentity ? " even with identical call IDs and arguments" : ""}`, async (t) => { const readOne: ToolUse = { id: "toolu_concurrent_one", name: "read", arguments: { path: "one" } }; const readTwo: ToolUse = sharedIdentity ? { ...readOne } : { id: "toolu_concurrent_two", name: "read", arguments: { path: "two" } }; diff --git a/tests/fixtures/claude-code/fake-claude.mjs b/tests/fixtures/claude-code/fake-claude.mjs index 7438444..2b0ba66 100644 --- a/tests/fixtures/claude-code/fake-claude.mjs +++ b/tests/fixtures/claude-code/fake-claude.mjs @@ -15,6 +15,7 @@ */ import { readFileSync, writeFileSync } from "node:fs"; import { on } from "node:events"; +import { createInterface } from "node:readline"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; @@ -56,8 +57,29 @@ async function writeReply(text) { const mcpConfig = readFileSync(process.argv[process.argv.indexOf("--mcp-config") + 1], "utf8"); let input = ""; -for await (const chunk of process.stdin) input += chunk; -if (process.env.FAKE_CLAUDE_STDIN) writeFileSync(process.env.FAKE_CLAUDE_STDIN, input); +let receiveUser; +const userReceived = new Promise((resolve) => { receiveUser = resolve; }); +createInterface({ input: process.stdin }).on("line", async (line) => { + input += `${line}\n`; + const message = JSON.parse(line); + if (message.type !== "control_request") { + if (process.env.FAKE_CLAUDE_STDIN) writeFileSync(process.env.FAKE_CLAUDE_STDIN, input); + receiveUser(); return; + } + if (message.request.subtype === "initialize") { + if (process.env.FAKE_CLAUDE_INIT === "ignore") return; + await writeLine(JSON.stringify({ type: "control_response", response: { + request_id: message.request_id, subtype: process.env.FAKE_CLAUDE_INIT === "error" ? "error" : "success", + response: {}, error: "unsupported initialize", + } })); + } else if (message.request.subtype === "get_context_usage") { + await writeLine(JSON.stringify({ type: "control_response", response: { + request_id: message.request_id, subtype: "success", + response: { maxTokens: process.env.FAKE_CLAUDE_WINDOW ? JSON.parse(process.env.FAKE_CLAUDE_WINDOW) : null }, + } })); + } +}); +await userReceived; let bridge = null; const results = []; diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index 3ab0ea6..9af9c7d 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -66,7 +66,8 @@ describe("the model catalog projection", () => { assert.ok(Array.isArray(m.supportedLevels)); if (m.provider === "claude-code") assert.equal(m.cost, null); else assert.ok(m.cost && typeof m.cost.input === "number" && typeof m.cost.output === "number"); - assert.ok(m.contextWindow > 0); + if (m.provider === "claude-code") assert.equal(m.contextWindow, null); + else assert.ok(m.contextWindow !== null && m.contextWindow > 0); } const glm = models.find((m) => m.id === "z-ai/glm-5.3-flash"); assert.ok(glm, "glm-5.3-flash in catalog"); @@ -384,9 +385,9 @@ describe("the claude-code provider (native CLI authentication, no LearnHarness k }); }); - it("catalogContextLimits() has claude-code:opus when the CLI is installed", () => { + it("catalogContextLimits() never advertises placeholder CLI limits, even when installed", () => { withClaudeInstalled(true, () => { - assert.ok((catalogContextLimits().get("claude-code:opus") ?? 0) > 0); + assert.equal(catalogContextLimits().has("claude-code:opus"), false); }); }); From ab76e4ff50681ca7959ab3cf58c56c5cc0f6ca22 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 21:13:43 +0200 Subject: [PATCH 76/87] fix: clarify Claude CLI billing and compact status copy --- components/settings/claude-code-status.tsx | 2 +- components/settings/connection-controls.tsx | 5 +++-- components/stats-bar.tsx | 7 ++++--- tests/claude-code-context-ui.test.tsx | 9 ++++++--- tests/claude-code-status-ui.test.tsx | 13 +++++++++++++ tests/claude-code-usage.test.tsx | 13 +++++++++++++ 6 files changed, 40 insertions(+), 9 deletions(-) diff --git a/components/settings/claude-code-status.tsx b/components/settings/claude-code-status.tsx index cc5fd33..65c600a 100644 --- a/components/settings/claude-code-status.tsx +++ b/components/settings/claude-code-status.tsx @@ -40,7 +40,7 @@ export function ClaudeCodeInstallationStatus() { return () => request.current?.abort(); }, [refresh]); return
- void refresh()} actionTestid="claude-code-check-again"> + void refresh()} actionTestid="claude-code-check-again"> {busy ? "Checking…" : claudeCodeStatusLabel(status)}

Install and authenticate in Claude Code outside LearnHarness. Authentication is not checked. Your Claude Code configuration determines billing.

diff --git a/components/settings/connection-controls.tsx b/components/settings/connection-controls.tsx index 5a92319..cd67f47 100644 --- a/components/settings/connection-controls.tsx +++ b/components/settings/connection-controls.tsx @@ -4,11 +4,12 @@ import { createPortal } from "react-dom"; import { useBackdropDismiss } from "@/components/backdrop-dismiss"; import { Button } from "@/components/ui/button"; -export function ConnectionRow({ label, action, disabled, onAction, children, testid, actionTestid, labelClassName }: { label: string; action: string; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string; labelClassName?: string }) { +export function ConnectionRow({ label, action, actionVariant, disabled, onAction, children, testid, actionTestid, labelClassName }: { label: string; action: string; actionVariant?: "outline" | "ghost"; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string; labelClassName?: string }) { + const variant = actionVariant ?? (action === "Connect" ? "outline" : "ghost"); return
{label} {children} - +
; } diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx index 1abbc2b..d3e9811 100644 --- a/components/stats-bar.tsx +++ b/components/stats-bar.tsx @@ -109,7 +109,8 @@ export function StatsBar({ // otherwise "—" (never a fake $0.0000). const pricedCount = turns.length - unpriced - subscription; const providers = pricing?.subscriptionProviders ?? subscriptionLabels(turns.map((turn) => turn.model)); - const costLabel = planCostLabel(pricedCount > 0 ? cost : null, providers); + const cliBilled = turns.some((turn) => turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`)); + const costLabel = planCostLabel(pricedCount > 0 ? cost : null, cliBilled ? [...providers, "CLI"] : providers); return (
{selectedModel?.startsWith(`${CLAUDE_CODE_PROVIDER}:`) && ( -

The window is reported by Claude Code’s local summary and may reflect its auto-compaction budget rather than the model’s full maximum. Unavailable windows are omitted.

+

Window reported by Claude Code; may be lower for auto-compaction.

)}
{subscription ? "Usage" : "Total cost"} @@ -193,7 +194,7 @@ export function StatsBar({ {turn.steps.length} steps - {subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))} + {turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`) ? "CLI" : subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))} ); diff --git a/tests/claude-code-context-ui.test.tsx b/tests/claude-code-context-ui.test.tsx index 3b026ac..9aa62f3 100644 --- a/tests/claude-code-context-ui.test.tsx +++ b/tests/claude-code-context-ui.test.tsx @@ -7,7 +7,7 @@ import { parseHTML } from "linkedom"; import { StatsBar } from "../components/stats-bar.tsx"; import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; import { toModelDto } from "../src/server/model-catalog.ts"; -import type { ModelCatalogResponse } from "../src/core/index.ts"; +import type { ModelCatalogResponse, TurnUsage } from "../src/core/index.ts"; const dom = parseHTML(""); Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, @@ -27,14 +27,17 @@ test("compact CLI context is numeric only; effective-window explanation appears throw new Error(`unexpected request ${url}`); }) as typeof fetch; host = document.createElement("div"); document.body.append(host); root = createRoot(host); - const render = (window: { model: string; tokens: number } | null) => root!.render(); + const turn: TurnUsage = { id: "cli-turn", messageId: "cli-message", model: "claude-code:opus", at: "2026-10-28T00:00:00Z", steps: [{ inputTokens: 42000, outputTokens: 1, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined }] }; + const render = (window: { model: string; tokens: number } | null) => root!.render(); await act(async () => render({ model: "claude-code:opus", tokens: 200000 })); assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k"); assert(!document.body.textContent?.includes("auto-compaction")); + assert.equal(document.querySelector('[data-testid="stats-cost"]')?.textContent, "CLI"); const summary = document.querySelector('[data-testid="stats-summary"]')!; assert(!summary.textContent?.includes("window")); assert(!summary.textContent?.includes("budget")); await act(async () => summary.click()); - assert.match(document.querySelector('[data-testid="stats-detail"]')?.textContent ?? "", /auto-compaction budget rather than the model’s full maximum/); + assert.match(document.querySelector('[data-testid="stats-detail"]')?.textContent ?? "", /Window reported by Claude Code; may be lower for auto-compaction\./); + assert.equal(document.querySelector('[data-testid="stats-detail"] li > span:last-child > span:last-child')?.textContent, "CLI"); assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k"); await act(async () => render(null)); assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k"); diff --git a/tests/claude-code-status-ui.test.tsx b/tests/claude-code-status-ui.test.tsx index 015fe66..1afbf0d 100644 --- a/tests/claude-code-status-ui.test.tsx +++ b/tests/claude-code-status-ui.test.tsx @@ -6,6 +6,7 @@ import { createRoot, type Root } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; import { parseHTML } from "linkedom"; import { ClaudeCodeInstallationStatus, claudeCodeStatusLabel } from "../components/settings/claude-code-status.tsx"; +import { ConnectionRow } from "../components/settings/connection-controls.tsx"; import type { ClaudeCodeStatus } from "../src/core/contracts/claude-code.ts"; const dom = parseHTML(""); @@ -33,6 +34,18 @@ test("row is informational, disclaims auth/billing readiness, and links only to assert.equal((html.match(/")); + assert.match(html, /data-variant="outline"/); + assert.match(html, /data-size="xs"/); +}); + +test("recheck matches the existing Connect button styling without restyling Manage actions", () => { + const row = (action: string) => renderToStaticMarkup( {}} />); + const connect = parseHTML(row("Connect")).document.querySelector("button")!; + const manage = parseHTML(row("Manage")).document.querySelector("button")!; + const check = parseHTML(renderToStaticMarkup()).document.querySelector("button")!; + assert.equal(check.getAttribute("class"), connect.getAttribute("class")); + assert.equal(check.getAttribute("data-variant"), "outline"); + assert.equal(manage.getAttribute("data-variant"), "ghost"); }); for (const [value, text] of [ diff --git a/tests/claude-code-usage.test.tsx b/tests/claude-code-usage.test.tsx index 7c781fc..8415d2f 100644 --- a/tests/claude-code-usage.test.tsx +++ b/tests/claude-code-usage.test.tsx @@ -25,6 +25,7 @@ for (const model of CLAUDE_CODE_MODELS) test(`${model.id}: token usage is retain assert.equal(jobCost({ usage: accumulateJobUsage(undefined, id, step) }), null); const html = renderToStaticMarkup(); assert(!html.includes("$0")); + assert.match(html, /data-testid="stats-cost">CLI<\/span>/); const summary = graphCostSummary({ chats: [{ title: "CLI fixture", extractions: [{ startedAt: 1, at: turn.at, durationMs: 1, stages: { extractor: { model: id, steps: [step] }, auditor: { model: id, steps: [step] } }, }] }], sources: [] }, callCost); @@ -32,6 +33,18 @@ for (const model of CLAUDE_CODE_MODELS) test(`${model.id}: token usage is retain assert.equal(summary.runs[0]!.dollars, null); }); +test("mixed session totals retain known API dollars and subscription labels alongside CLI billing", () => { + const cli: TurnUsage = { id: "cli", messageId: "cli", at: "2026-10-28T00:00:00Z", model: "claude-code:sonnet", steps: [step] }; + const api = { ...cli, id: "api", messageId: "api", model: "openai:fixture" }; + const pricing = { cost: 0.12, unpriced: 1, turnCosts: { cli: null, api: 0.12 } }; + const html = renderToStaticMarkup(); + assert.match(html, /data-testid="stats-cost">\$0\.1200 \+ CLI<\/span>/); + const subscription = { ...cli, id: "plan", messageId: "plan", model: "chatgpt:fixture" }; + const mixed = renderToStaticMarkup(); + assert.match(mixed, /data-testid="stats-cost">\$0\.1200 \+ ChatGPT \+ CLI<\/span>/); + assert.equal(callCost(step, cli.model), null, "CLI stays unpriced in accounting"); +}); + test("model-catalog API and menus never advertise the CLI adapter's placeholder zero rates", () => { const models = CLAUDE_CODE_MODELS.map(toModelDto); assert(models.every((model) => model.cost === null)); From c9c8cead57d69f83c9d2c3a3f9667d6711010cef Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 21:37:03 +0200 Subject: [PATCH 77/87] feat: require explicit opt-in for Claude Code use --- components/model-catalog.ts | 2 +- components/settings-signal.ts | 2 +- components/settings/claude-code-status.tsx | 90 ++++++++++++-- src/core/settings.ts | 14 ++- src/server/chat-handler.ts | 9 +- src/server/claude-code-config.ts | 13 ++ src/server/claude-code.ts | 3 + src/server/keys.ts | 7 +- src/server/model-catalog.ts | 5 +- src/server/settings-body.ts | 10 ++ tests/claude-code-harness.test.ts | 73 +++++++++-- tests/claude-code-opt-in.test.ts | 98 +++++++++++++++ tests/claude-code-status-http.test.ts | 9 +- tests/claude-code-status-ui.test.tsx | 136 ++++++++++++++------- tests/model-catalog.test.ts | 32 +++-- tests/settings-body.test.ts | 2 +- 16 files changed, 417 insertions(+), 88 deletions(-) create mode 100644 src/server/claude-code-config.ts create mode 100644 tests/claude-code-opt-in.test.ts diff --git a/components/model-catalog.ts b/components/model-catalog.ts index 6a97320..20724d2 100644 --- a/components/model-catalog.ts +++ b/components/model-catalog.ts @@ -39,7 +39,7 @@ export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; catalogReaders.add(read); if (first) { stopCatalogSignals = onSettingsSaved((section) => { - if (section === "keys") { + if (section === "keys" || section === "claudeCode") { catalogGeneration++; catalogInflight = undefined; catalogCache = undefined; fetchCatalog(); } else if (section === "models") fetchCatalog(true); diff --git a/components/settings-signal.ts b/components/settings-signal.ts index 47f885d..da4dddf 100644 --- a/components/settings-signal.ts +++ b/components/settings-signal.ts @@ -20,7 +20,7 @@ export const SETTINGS_SAVED_EVENT = "lh:settings-saved"; // "keys" joined the roster (zero-keys wave, 2026-09-25): keys-section // dispatches it after save AND delete — deletions used to be invisible to // the zero-keys pill and the setup dialog's auto-advance. -export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction" | "dictation"; +export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction" | "dictation" | "claudeCode"; /** Writers call this after the PUT resolves ok — never before (a failed * write notifies nothing; the record is unchanged). */ diff --git a/components/settings/claude-code-status.tsx b/components/settings/claude-code-status.tsx index 65c600a..3626c2d 100644 --- a/components/settings/claude-code-status.tsx +++ b/components/settings/claude-code-status.tsx @@ -1,7 +1,9 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "@/src/core"; -import { ConnectionRow } from "./connection-controls.tsx"; +import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus, type UserSettings } from "@/src/core"; +import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal"; +import { Button } from "@/components/ui/button"; +import { Toggle } from "./controls"; export function claudeCodeStatusLabel(status: ClaudeCodeStatus | null): string { switch (status?.state) { @@ -13,10 +15,65 @@ export function claudeCodeStatusLabel(status: ClaudeCodeStatus | null): string { } export function ClaudeCodeInstallationStatus() { + const [enabled, setEnabled] = useState(false); + const [loaded, setLoaded] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); const [status, setStatus] = useState(null); - const [busy, setBusy] = useState(true); + const [busy, setBusy] = useState(false); const request = useRef(null); + const settingsRead = useRef(null); + const settingsWrite = useRef(null); + + const read = useCallback(async () => { + settingsRead.current?.abort(); + const controller = new AbortController(); + settingsRead.current = controller; + setLoaded(false); + try { + const response = await fetch("/api/settings", { cache: "no-store", signal: controller.signal }); + if (!response.ok) throw new Error("settings unavailable"); + const body = await response.json() as { settings: UserSettings | null }; + if (!controller.signal.aborted) { + setEnabled(body.settings?.claudeCode?.enabled === true); + setLoaded(true); + setError(null); + } + } catch { + if (!controller.signal.aborted) setError("Could not load Claude Code settings."); + } + }, []); + useEffect(() => { + void read(); + return () => { settingsRead.current?.abort(); settingsWrite.current?.abort(); }; + }, [read]); + useSettingsSaved((section) => { if (section === "claudeCode") void read(); }); + + const save = async (next: boolean) => { + const controller = new AbortController(); + settingsWrite.current = controller; + settingsRead.current?.abort(); + setSaving(true); + setError(null); + try { + const response = await fetch("/api/settings/claudeCode", { method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: next }), signal: controller.signal }); + if (!response.ok) throw new Error("settings write failed"); + const body = await response.json() as { settings: UserSettings }; + if (typeof body.settings?.claudeCode?.enabled !== "boolean") throw new Error("invalid settings response"); + if (!controller.signal.aborted) { + setEnabled(body.settings.claudeCode.enabled); + notifySettingsSaved("claudeCode"); + } + } catch { + if (!controller.signal.aborted) setError("Could not save Claude Code settings."); + } finally { + if (!controller.signal.aborted) setSaving(false); + } + }; + const refresh = useCallback(async () => { + if (!enabled) return; request.current?.abort(); const controller = new AbortController(); request.current = controller; @@ -34,16 +91,27 @@ export function ClaudeCodeInstallationStatus() { } finally { if (!controller.signal.aborted) setBusy(false); } - }, []); + }, [enabled]); useEffect(() => { - void refresh(); + if (enabled) void refresh(); + else { request.current?.abort(); setStatus(null); setBusy(false); } return () => request.current?.abort(); - }, [refresh]); + }, [enabled, refresh]); + return
- void refresh()} actionTestid="claude-code-check-again"> - {busy ? "Checking…" : claudeCodeStatusLabel(status)} - -

Install and authenticate in Claude Code outside LearnHarness. Authentication is not checked. Your Claude Code configuration determines billing.

-

Minimum version: {CLAUDE_CODE_MINIMUM_VERSION}. Meeting this minimum does not guarantee MCP compatibility, model access, or available quota. Claude Code setup ↗

+
+ Claude Code + void save(next)} /> +
+ {error &&

{error}

} + {enabled && <> +
+ {busy ? "Checking…" : claudeCodeStatusLabel(status)} + +
+

Install and authenticate Claude Code in your terminal, then select a Claude Code model here. LearnHarness starts it when needed—no background process required.

+

Checks installation/version only—not authentication, model access, or quota. Claude Code controls billing. Requires v{CLAUDE_CODE_MINIMUM_VERSION}+. Setup ↗

+

Verify AI claims independently—they may be inaccurate, incomplete, misleading, or outdated.

+ }
; } diff --git a/src/core/settings.ts b/src/core/settings.ts index f7966f1..2e8d3ae 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -29,6 +29,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts"; +import { CLAUDE_CODE_PROVIDER } from "./contracts/claude-code.ts"; import type { ModelRole } from "../../config.ts"; import type { ChatSettings } from "./contracts/chat.ts"; import type { SearchEngineId } from "./search.ts"; @@ -156,10 +157,11 @@ export interface ProviderKeyMissing { /** The refusal wording's invariant tail — the matcher below keys on it * (the label prefix varies by provider). */ const PROVIDER_KEY_MISSING_TAIL = "key is set — add one in Settings → Keys"; +const CLAUDE_CODE_UNAVAILABLE = "Claude Code is unavailable. Enable and set it up in Settings → Providers."; export function providerKeyMissing(provider: string, slots: ModelSlotName[]): ProviderKeyMissing { const label = AUTH_PROVIDER_KEYS.find((p) => p.id === provider)?.label ?? provider; return { - error: `No ${label} ${PROVIDER_KEY_MISSING_TAIL}`, + error: provider === CLAUDE_CODE_PROVIDER ? CLAUDE_CODE_UNAVAILABLE : `No ${label} ${PROVIDER_KEY_MISSING_TAIL}`, code: PROVIDER_KEY_MISSING_CODE, provider, slots, @@ -170,7 +172,7 @@ export function providerKeyMissing(provider: string, slots: ModelSlotName[]): Pr * scary-error) — the envelope's home module owns the string so the * writer and the matcher can never drift. */ export function isProviderKeyRefusal(error: string | undefined): boolean { - return !!error && error.endsWith(PROVIDER_KEY_MISSING_TAIL); + return !!error && (error.endsWith(PROVIDER_KEY_MISSING_TAIL) || error === CLAUDE_CODE_UNAVAILABLE); } /** The typed throw for in-process seams (extraction pipeline) — the * ProviderKeyMissing sibling of ModelsNotConfiguredError: callers catch @@ -368,6 +370,8 @@ export interface UserSettings { version: number; general?: GeneralSettings; dictation?: DictationSettings; + /** Explicit app-wide opt-in; absent/invalid is off, including older records. */ + claudeCode?: { enabled?: boolean }; agent?: AgentSettings; /** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */ models?: ModelsSettings; @@ -631,6 +635,12 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null { ? { language: (raw as DictationSettings).language } : {}; } + if (r.claudeCode !== undefined) { + const raw = r.claudeCode; + out.claudeCode = raw !== null && typeof raw === "object" && !Array.isArray(raw) + && typeof (raw as Record).enabled === "boolean" + ? { enabled: (raw as { enabled: boolean }).enabled } : {}; + } if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) { out.agent = sanitizeAgent(r.agent as Record); } diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index b0eef2a..21ccc7e 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -7,7 +7,7 @@ import { type TeachingAgent, } from "../agent/teacher-agent.ts"; import { piEngineForSpec, type PiEngine } from "../agent/models.ts"; -import { connectionBackgroundAllowed, connectionScopeCurrent, isSubscriptionProvider } from "../agent/subscription-connections.ts"; +import { connectionBackgroundAllowed as subscriptionBackgroundAllowed, connectionScopeCurrent, isSubscriptionProvider } from "../agent/subscription-connections.ts"; import { nativeSubscriptionError } from "../agent/subscription-stream.ts"; import { chatGPTStreamError } from "../agent/chatgpt-stream.ts"; import { SubscriptionRequestCycle, type SubscriptionDispatchLifecycle } from "../agent/subscription-request-cycle.ts"; @@ -33,6 +33,7 @@ import { providerEventNote } from "../agent/provider-events.ts"; import { effectiveSettings, stores } from "./stores.ts"; import { searchAvailable, searchProvider } from "./search.ts"; import { providerKeyPresent } from "./keys.ts"; +import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts"; import { extractionEnabled, graphToolsEnabled, @@ -80,6 +81,12 @@ import type {CallUsage} from "../core/contracts.ts"; * for write-ahead persistence, turn liveness, and the turn services. */ +/** Fresh check only at new background-run admission; never cancel a run + * already in progress when the opt-in is switched off. */ +function connectionBackgroundAllowed(provider: string): boolean { + return subscriptionBackgroundAllowed(provider) && (provider !== CLAUDE_CODE_PROVIDER || providerKeyPresent(provider)); +} + function settlementErrorText(error: unknown): string { return error instanceof TurnPersistenceError ? error.message : "An error occurred."; } diff --git a/src/server/claude-code-config.ts b/src/server/claude-code-config.ts new file mode 100644 index 0000000..9deffc2 --- /dev/null +++ b/src/server/claude-code-config.ts @@ -0,0 +1,13 @@ +import { getClaudeBinary } from "../agent/claude-code/binary.ts"; +import { settingsStore } from "../store/settings.ts"; + +/** Fresh admission state, not a process-wide cache. Existing runs retain + * their engines; only new chat/job/extraction/compaction admissions consult it. */ +export function claudeCodeEnabled(): boolean { + return settingsStore.readSettings()?.claudeCode?.enabled === true; +} + +/** Installation/opt-in availability only, never authenticated readiness. */ +export function claudeCodeAvailable(): boolean { + return claudeCodeEnabled() && getClaudeBinary() !== null; +} diff --git a/src/server/claude-code.ts b/src/server/claude-code.ts index bd40ebe..c6b0c3b 100644 --- a/src/server/claude-code.ts +++ b/src/server/claude-code.ts @@ -1,6 +1,7 @@ import { inspectClaudeInstallation } from "../agent/claude-code/installation.ts"; import { initAuthEnv } from "./keys.ts"; import { subscriptionRequestOrigin } from "./web-subscription-http.ts"; +import { claudeCodeEnabled } from "./claude-code-config.ts"; /** The Settings status seam: initialize provenance before any child env is copied. */ export async function claudeCodeInstallationStatus() { @@ -13,6 +14,7 @@ export async function claudeCodeInstallationStatus() { export async function claudeCodeInstallationResponse( request: Request, inspect = claudeCodeInstallationStatus, + enabled = claudeCodeEnabled, ): Promise { const origin = subscriptionRequestOrigin(request); const supplied = request.headers.get("origin"); @@ -23,5 +25,6 @@ export async function claudeCodeInstallationResponse( || (supplied !== null && supplied !== origin)) { return Response.json({ error: "Claude Code checks require a same-origin LearnHarness request." }, { status: 403, headers }); } + if (!enabled()) return Response.json({ error: "Claude Code is disabled." }, { status: 403, headers }); return Response.json(await inspect(), { headers }); } diff --git a/src/server/keys.ts b/src/server/keys.ts index 03b8d4c..502bce2 100644 --- a/src/server/keys.ts +++ b/src/server/keys.ts @@ -53,7 +53,7 @@ import { } from "../core/auth-keys.ts"; import { authStore } from "../store/auth.ts"; import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts"; -import { getClaudeBinary } from "../agent/claude-code/binary.ts"; +import { claudeCodeAvailable } from "./claude-code-config.ts"; import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts"; // Shared HMR-stable provenance lets external CLIs exclude file-only injections @@ -189,8 +189,9 @@ export function keyValue(id: string): string | undefined { * (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take * no key at all: nothing to gate, present by definition. */ export function providerKeyPresent(provider: string): boolean { - // Presence only, not authentication readiness. Launch performs the version gate. - if (provider === CLAUDE_CODE_PROVIDER) return getClaudeBinary() !== null; + // Explicit opt-in + installation only, never authentication readiness. + // Consulted at admission, not inside already-running agent loops. + if (provider === CLAUDE_CODE_PROVIDER) return claudeCodeAvailable(); if (isSubscriptionProvider(provider)) return subscriptionConnected(provider); const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider); return info ? keyValue(info.envVar) !== undefined : true; diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index 6605788..f2fc362 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -31,6 +31,7 @@ export async function refreshConnectionCatalog(provider?: string): Promise ]); } function connectionModel(provider: string, id: string): Model | undefined { + if (provider === CLAUDE_CODE_PROVIDER && !claudeCodeAvailable()) return undefined; return provider === "chatgpt" ? chatGPTModel(id) : nativeSubscriptionId(provider) ? nativeSubscriptionModel(provider, id) : piModels().getModel(provider, id); } function connectionProviderIds(): string[] { return [...piModels().getProviders().map((p) => p.id), "chatgpt", ...NATIVE_SUBSCRIPTIONS.map((p) => p.logicalId)]; } @@ -48,7 +49,7 @@ import type { } from "../core/contracts/models.ts"; import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts"; import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.ts"; -import { getClaudeBinary } from "../agent/claude-code/binary.ts"; +import { claudeCodeAvailable } from "./claude-code-config.ts"; import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts"; import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts"; @@ -143,7 +144,7 @@ export function catalogProviders(): CatalogProvider[] { keyPresent: keyValue(p.envVar) !== undefined, })).concat([ { id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() }, - { id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: getClaudeBinary() !== null }, + { id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: claudeCodeAvailable() }, ...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })), ]).sort((a, b) => a.id.localeCompare(b.id)); } diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index 9b1a173..81ae0f2 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -91,6 +91,15 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult { + const raw = asRecord(body); + if (!raw) return { ok: false, error: "Invalid JSON body" }; + if (raw.enabled === undefined) return { ok: true, settings: {} }; + if (typeof raw.enabled !== "boolean") return { ok: false, error: "enabled must be a boolean" }; + return { ok: true, settings: { enabled: raw.enabled } }; +} + /** agent.models: only the six known background roles; each override's * fields mirror ChatSettings (model pair + independent thinkingLevel). */ function parseRoleOverride(value: unknown, role: string): { ok: true; override: RoleModelOverride } | { ok: false; error: string } { @@ -397,6 +406,7 @@ export const SETTINGS_SECTIONS = { compaction: parseCompactionSettingsBody, models: parseModelsSettingsBody, dictation: parseDictationSettingsBody, + claudeCode: parseClaudeCodeSettingsBody, } as const; export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS; diff --git a/tests/claude-code-harness.test.ts b/tests/claude-code-harness.test.ts index 78bedb2..aa6d735 100644 --- a/tests/claude-code-harness.test.ts +++ b/tests/claude-code-harness.test.ts @@ -15,19 +15,26 @@ import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; -import test, { type TestContext } from "node:test"; +import test, { after, type TestContext } from "node:test"; -import { PATHS } from "../config.ts"; -import { LIVE_PROCESS_IDLE_TIMEOUT_MS, liveProcessIdleTimeoutMs } from "../src/agent/claude-code/live-processes.ts"; -import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; -import { createClaudeCodeStreamFunction, type RunClaude } from "../src/agent/claude-code/stream.ts"; +import type { RunClaude } from "../src/agent/claude-code/stream.ts"; import type { PiEngine } from "../src/agent/models.ts"; -import { createTeacherAgent } from "../src/agent/teacher-agent.ts"; -import { cliContextWindowOf, danglingQuestionOf } from "../src/core/native-messages.ts"; -import { handleChatRequest } from "../src/server/chat-handler.ts"; -import { isTurnRunning } from "../src/server/turn-liveness.ts"; -import { runningTurn } from "../src/server/turn-runs.ts"; -import { chatStore } from "../src/store/chats.ts"; + +// Opt-in writes must not race another test process's settings record. +const testData = mkdtempSync(join(tmpdir(), "lh-cli-harness-data-")); +process.env.LH_DATA_DIR = testData; +after(() => rmSync(testData, { recursive: true, force: true })); +const { PATHS, SETTINGS_FILE } = await import("../config.ts"); +const { readSettings, writeSettings } = await import("../src/store/settings.ts"); +const { LIVE_PROCESS_IDLE_TIMEOUT_MS, liveProcessIdleTimeoutMs } = await import("../src/agent/claude-code/live-processes.ts"); +const { CLAUDE_CODE_MODELS } = await import("../src/agent/claude-code/provider.ts"); +const { createClaudeCodeStreamFunction } = await import("../src/agent/claude-code/stream.ts"); +const { createTeacherAgent } = await import("../src/agent/teacher-agent.ts"); +const { cliContextWindowOf, danglingQuestionOf } = await import("../src/core/native-messages.ts"); +const { handleChatRequest } = await import("../src/server/chat-handler.ts"); +const { isTurnRunning } = await import("../src/server/turn-liveness.ts"); +const { runningTurn } = await import("../src/server/turn-runs.ts"); +const { chatStore } = await import("../src/store/chats.ts"); const FAKE_CLAUDE = fileURLToPath(new URL("./fixtures/claude-code/fake-claude.mjs", import.meta.url)); const TEXT_FIXTURE = fileURLToPath(new URL("./fixtures/claude-code/text.jsonl", import.meta.url)); @@ -61,6 +68,9 @@ function toAskScenario(): string[] { } function createFakeRun(t: TestContext, scenarios: string[][]): FakeRun { + const settings = readSettings(); + writeSettings({ ...(settings ?? { version: 1 }), claudeCode: { enabled: true } }); + t.after(() => { if (settings) writeSettings(settings); else rmSync(SETTINGS_FILE, { force: true }); }); const dir = mkdtempSync(join(tmpdir(), "claude-code-harness-")); t.after(() => rmSync(dir, { recursive: true, force: true })); // Satisfy the real presence gate; this marker is NEVER executed. All work @@ -155,6 +165,47 @@ async function waitForExit(child: ChildProcessWithoutNullStreams): Promise await once(child, "exit", { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) }); } +test("disabled opt-in refuses a saved CLI chat before write-ahead, liveness, or agent creation", async (t) => { + const fakeRun = createFakeRun(t, [toAskScenario()]); + const chatId = createClaudeCodeChat(t); + writeSettings({ ...(readSettings() ?? { version: 1 }), claudeCode: { enabled: false } }); + const before = chatStore.getChat(chatId)!.messages; + const response = await handleChatRequest(new Request("http://test/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chatId, input: { type: "text", text: "must not start" } }) }), () => assert.fail("disabled work must not build an agent")); + assert.equal(response.status, 503); + assert.match((await response.json()).error, /Enable and set it up in Settings/); + assert.equal(isTurnRunning(chatId), false); assert.equal(fakeRun.children.length, 0); + assert.deepEqual(chatStore.getChat(chatId)!.messages, before); + assert.deepEqual(chatStore.getChat(chatId)!.settings?.model, { provider: "claude-code", id: "sonnet" }); +}); + +test("switching off blocks future requests but does not interrupt an already admitted CLI turn", async (t) => { + const scenario = [JSON.stringify({ fake: "checkpoint" }), JSON.stringify({ fake: "wait-for-message" }), ...readFileSync(TEXT_FIXTURE, "utf8").trim().split("\n")]; + const fakeRun = createFakeRun(t, [scenario]); + const run = fakeRun.runClaude; + let ready!: (child: ChildProcessWithoutNullStreams) => void; + let failed!: (error: unknown) => void; + const checkpoint = new Promise((resolve, reject) => { ready = resolve; failed = reject; }); + fakeRun.runClaude = async (args, options) => { + const child = await run(args, options); assert(child); + void once(child, "message", { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) }).then(() => ready(child), failed); + return child; + }; + const chatId = createClaudeCodeChat(t); + const response = await handleChatRequest(new Request("http://test/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chatId, input: { type: "text", text: "complete this admitted fixture" } }) }), createAgentFactory(fakeRun)); + assert.equal(response.status, 200); + const child = await checkpoint; + const handle = runningTurn(chatId); assert(handle); + const finished = new Response(handle.subscribe()).text(); + writeSettings({ ...(readSettings() ?? { version: 1 }), claudeCode: { enabled: false } }); + assert.equal(child.signalCode, null, "opt-out must not kill an already running process"); + child.send("continue"); await finished; + assert(getLastAssistantText(chatId).length > 0, "the admitted turn completes normally"); + assert.equal(readSettings()?.claudeCode?.enabled, false); + await waitForExit(child); +}); + test("a question answered before the idle timeout continues the waiting CLI process with the answer", async (t) => { const fakeRun = createFakeRun(t, [toAskScenario()]); const run = fakeRun.runClaude; diff --git a/tests/claude-code-opt-in.test.ts b/tests/claude-code-opt-in.test.ts new file mode 100644 index 0000000..b0e0026 --- /dev/null +++ b/tests/claude-code-opt-in.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import test, { after, type TestContext } from "node:test"; + +const testData = mkdtempSync(join(tmpdir(), "lh-cli-opt-in-data-")); +process.env.LH_DATA_DIR = testData; +after(() => rmSync(testData, { recursive: true, force: true })); +const { SETTINGS_FILE } = await import("../config.ts"); +const { PUT } = await import("../app/api/settings/[section]/route.ts"); +const { sanitizeUserSettings, providerKeyMissing } = await import("../src/core/settings.ts"); +const { parseClaudeCodeSettingsBody } = await import("../src/server/settings-body.ts"); +const { claudeCodeAvailable, claudeCodeEnabled } = await import("../src/server/claude-code-config.ts"); +const { claudeCodeInstallationResponse } = await import("../src/server/claude-code.ts"); +const { modelCatalog, validateModelsSettings } = await import("../src/server/model-catalog.ts"); +const { providerKeyPresent } = await import("../src/server/keys.ts"); +const { readSettings, writeSettings } = await import("../src/store/settings.ts"); +const { chatStore } = await import("../src/store/chats.ts"); +const { jobStore } = await import("../src/store/jobs.ts"); +const { createJobRunner } = await import("../src/agent/subagents/runner.ts"); +const { CLAUDE_CODE_MODELS } = await import("../src/agent/claude-code/provider.ts"); + +function setup(t: TestContext) { + const previous = readSettings(); + const path = process.env.PATH; + const dir = mkdtempSync(join(tmpdir(), "claude-opt-in-")); + // Never executed: only installation discovery observes this marker. + writeFileSync(join(dir, process.platform === "win32" ? "claude.exe" : "claude"), "", { mode: 0o755 }); + process.env.PATH = `${dir}${delimiter}${path ?? ""}`; + t.after(() => { + if (previous) writeSettings(previous); else rmSync(SETTINGS_FILE, { force: true }); + if (path === undefined) delete process.env.PATH; else process.env.PATH = path; + rmSync(dir, { recursive: true, force: true }); + }); + writeSettings({ version: 1 }); +} +async function save(body: unknown) { + return PUT(new Request("http://test/api/settings/claudeCode", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }), + { params: Promise.resolve({ section: "claudeCode" }) }); +} + +test("opt-in parses strictly and malformed stored values never enable the CLI", () => { + assert.deepEqual(parseClaudeCodeSettingsBody({}), { ok: true, settings: {} }); + assert.deepEqual(parseClaudeCodeSettingsBody({ enabled: true, ignored: true }), { ok: true, settings: { enabled: true } }); + assert.deepEqual(parseClaudeCodeSettingsBody({ enabled: false }), { ok: true, settings: { enabled: false } }); + for (const raw of [true, null, [], "on", { enabled: "true" }, { enabled: 1 }]) assert.equal(parseClaudeCodeSettingsBody(raw).ok, false); + for (const raw of [null, [], true, "on", { enabled: "true" }, { enabled: 1 }]) { + assert.notEqual(sanitizeUserSettings({ version: 1, claudeCode: raw })?.claudeCode?.enabled, true); + } + assert.equal(sanitizeUserSettings({ version: 1 })?.claudeCode, undefined); +}); + +test("default off even with an installed binary; section writes toggle availability but preserve saved model pins and unrelated settings", async (t) => { + setup(t); + const models = { strong: { model: { provider: "claude-code", id: "sonnet" } }, cheap: { model: { provider: "claude-code", id: "haiku" } } }; + writeSettings({ version: 1, models, graph: { enabled: false }, futureSection: { keep: 1 } }); + assert.equal(claudeCodeEnabled(), false); assert.equal(claudeCodeAvailable(), false); + assert.equal(providerKeyPresent("claude-code"), false); + assert(!modelCatalog().models.some((m) => m.provider === "claude-code")); + assert.equal((await save({ enabled: true })).status, 200); + assert.equal(claudeCodeEnabled(), true); assert.equal(claudeCodeAvailable(), true); + assert.equal(modelCatalog().models.filter((m) => m.provider === "claude-code").length, 3); + assert.equal((await save({ enabled: false })).status, 200); + assert.equal(claudeCodeAvailable(), false); + assert(!modelCatalog().models.some((m) => m.provider === "claude-code")); + assert.deepEqual(readSettings()?.models, models); + assert.deepEqual(readSettings()?.graph, { enabled: false }); + assert.deepEqual(readSettings()?.futureSection, { keep: 1 }); + assert.equal(validateModelsSettings(models, models).ok, true, "saved unavailable pins remain repairable, not silently removed"); + assert.equal((await save({ enabled: "true" })).status, 400); assert.equal(claudeCodeEnabled(), false); + assert.equal((await save({})).status, 200); assert.equal(claudeCodeEnabled(), false); +}); + +test("off cannot launch a version probe even through an otherwise authorized HTTP request", async (t) => { + setup(t); + const request = new Request("http://test/api/claude-code", { headers: { "x-learnharness-claude-code": "1" } }); + const response = await claudeCodeInstallationResponse(request, async () => assert.fail("disabled CLI must not execute")); + assert.equal(response.status, 403); +}); + +test("a saved CLI background job is refused before model execution while opt-in is off", async (t) => { + setup(t); + const chat = chatStore.createChat("CLI opt-in job fixture"); + t.after(() => { jobStore.deleteJobsForChat(chat.id); chatStore.deleteChat(chat.id); }); + const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "cli-off-job" }); + let calls = 0; + const runner = createJobRunner({ chatStore, jobStore, deliver: async () => {}, + engineFor: () => ({ model: CLAUDE_CODE_MODELS[0]!, streamFn: () => { calls++; assert.fail("must not run a disabled CLI model"); } }), + providerKeyMissing: (provider, tier) => providerKeyPresent(provider) ? null : providerKeyMissing(provider, [tier]), + }); + await runner.runJob(job.id, { question: "Synthetic fixture only" }); + assert.equal(calls, 0); + const refused = jobStore.getJob(job.id)!; + assert.equal(refused.status, "error"); + assert.match(JSON.stringify(refused.result), /Enable and set it up in Settings/); + assert(!JSON.stringify(refused.result).includes("add one in Settings → Keys")); +}); diff --git a/tests/claude-code-status-http.test.ts b/tests/claude-code-status-http.test.ts index 1c8f746..b9bed2d 100644 --- a/tests/claude-code-status-http.test.ts +++ b/tests/claude-code-status-http.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { claudeCodeInstallationResponse } from "../src/server/claude-code.ts"; +import { claudeCodeInstallationResponse as respond } from "../src/server/claude-code.ts"; +const claudeCodeInstallationResponse: typeof respond = (request, inspect) => respond(request, inspect, () => true); function request(headers: Record = {}) { return new Request("http://localhost:3000/api/claude-code", { headers: { "x-learnharness-claude-code": "1", ...headers } }); @@ -20,6 +21,12 @@ for (const headers of [ assert.equal(response.headers.get("access-control-allow-origin"), null); }); +test("disabled opt-in never starts even a version probe", async () => { + let probes = 0; + const response = await respond(request(), async () => { probes++; return status; }, () => false); + assert.equal(response.status, 403); assert.equal(probes, 0); +}); + test("ordinary image/subresource GET without intent header cannot start a probe", async () => { const response = await claudeCodeInstallationResponse(new Request("http://localhost:3000/api/claude-code"), async () => assert.fail("must not probe")); assert.equal(response.status, 403); diff --git a/tests/claude-code-status-ui.test.tsx b/tests/claude-code-status-ui.test.tsx index 1afbf0d..ee92d29 100644 --- a/tests/claude-code-status-ui.test.tsx +++ b/tests/claude-code-status-ui.test.tsx @@ -7,45 +7,81 @@ import { renderToStaticMarkup } from "react-dom/server"; import { parseHTML } from "linkedom"; import { ClaudeCodeInstallationStatus, claudeCodeStatusLabel } from "../components/settings/claude-code-status.tsx"; import { ConnectionRow } from "../components/settings/connection-controls.tsx"; +import { useModelCatalog } from "../components/model-catalog.ts"; import type { ClaudeCodeStatus } from "../src/core/contracts/claude-code.ts"; const dom = parseHTML(""); -Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }); +Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, + CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true }); const originalFetch = globalThis.fetch; let root: Root | undefined, host: HTMLDivElement | undefined; const status = (state: ClaudeCodeStatus["state"], version: string | null = null): ClaudeCodeStatus => ({ state, version, minimumVersion: "2.1.259" }); const button = () => document.querySelector('[data-testid="claude-code-check-again"]')!; +const toggle = () => document.querySelector('[role="switch"]')!; const label = () => document.querySelector('[role="status"]')?.textContent; -async function mount() { +function fixture(probe: (init?: RequestInit) => Promise, enabled = true, failSave = false) { + const calls: string[] = []; + let on = enabled; + globalThis.fetch = (async (url, init) => { + calls.push(`${init?.method ?? "GET"} ${url}`); + if (url === "/api/settings") return Response.json({ settings: { version: 1, claudeCode: { enabled: on } } }); + if (url === "/api/settings/claudeCode") { + assert.equal(init?.method, "PUT"); assert.equal(new Headers(init.headers).get("Content-Type"), "application/json"); + if (failSave) return new Response(null, { status: 500 }); + on = JSON.parse(String(init.body)).enabled; + return Response.json({ settings: { version: 1, claudeCode: { enabled: on } } }); + } + if (url === "/api/models") return Response.json({ models: on ? [{ provider: "claude-code", id: "sonnet" }] : [] }); + assert.equal(url, "/api/claude-code"); + assert.equal(init?.cache, "no-store"); + assert.equal(new Headers(init?.headers).get("x-learnharness-claude-code"), "1"); + return probe(init); + }) as typeof fetch; + return calls; +} +async function mount(extra?: React.ReactNode) { host = document.createElement("div"); document.body.append(host); root = createRoot(host); - await act(async () => root!.render()); + await act(async () => root!.render(<>{extra})); } afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; globalThis.fetch = originalFetch; }); -test("row is informational, disclaims auth/billing readiness, and links only to official setup", () => { +test("initial render is collapsed and off, with no setup prose or version probe action", () => { const html = renderToStaticMarkup(); - assert.match(html, /Authentication is not checked/); - assert.match(html, /configuration determines billing/); - assert.match(html, /does not guarantee MCP compatibility/); - assert.match(html, /https:\/\/code.claude.com\/docs\/en\/setup/); + assert.match(html, /Claude Code/); assert.match(html, /aria-checked="false"/); assert.equal((html.match(/")); - assert.match(html, /data-variant="outline"/); - assert.match(html, /data-size="xs"/); + assert(!html.includes("Check again")); assert(!html.includes("Authenticate")); assert(!html.includes("Setup ↗")); }); -test("recheck matches the existing Connect button styling without restyling Manage actions", () => { - const row = (action: string) => renderToStaticMarkup( {}} />); - const connect = parseHTML(row("Connect")).document.querySelector("button")!; - const manage = parseHTML(row("Manage")).document.querySelector("button")!; - const check = parseHTML(renderToStaticMarkup()).document.querySelector("button")!; - assert.equal(check.getAttribute("class"), connect.getAttribute("class")); - assert.equal(check.getAttribute("data-variant"), "outline"); - assert.equal(manage.getAttribute("data-variant"), "ghost"); +test("off by default; enabling persists before probing and reveals the version/action row below the toggle", async () => { + const calls = fixture(async () => Response.json(status("not-found")), false); + await mount(); + assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!button()); + assert.deepEqual(calls, ["GET /api/settings"]); + await act(async () => toggle().click()); + assert.equal(toggle().getAttribute("aria-checked"), "true"); assert.equal(label(), "Not found"); + assert(calls.indexOf("PUT /api/settings/claudeCode") < calls.indexOf("GET /api/claude-code")); + assert.notEqual(toggle().parentElement, button().parentElement); + assert.equal(document.querySelector('[role="status"]')?.parentElement, button().parentElement); + assert.match(document.body.textContent ?? "", /no background process required/); + assert.match(document.body.textContent ?? "", /not authentication, model access, or quota/); + assert.match(document.body.textContent ?? "", /Claude Code controls billing/); + assert.match(document.body.textContent ?? "", /Verify AI claims independently—they may be inaccurate, incomplete, misleading, or outdated\./); + assert(document.querySelector("strong")?.textContent?.includes("Install and authenticate")); + assert.equal(document.querySelector("a")?.getAttribute("href"), "https://code.claude.com/docs/en/setup"); + await act(async () => toggle().click()); + assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!button()); assert.equal(label(), undefined); + assert.equal(calls.filter((call) => call === "GET /api/claude-code").length, 1); +}); + +test("recheck matches Connect styling; Manage remains ghost", async () => { + fixture(async () => Response.json(status("not-found"))); await mount(); + const row = (action: string) => parseHTML(renderToStaticMarkup( {}} />)).document.querySelector("button")!; + assert.equal(button().getAttribute("class"), row("Connect").getAttribute("class")); + assert.equal(button().getAttribute("data-variant"), "outline"); + assert.equal(row("Manage").getAttribute("data-variant"), "ghost"); }); for (const [value, text] of [ @@ -55,41 +91,51 @@ for (const [value, text] of [ [status("version-unchecked"), "Version could not be checked"], ] as const) test(`installation display: ${text}`, () => assert.equal(claudeCodeStatusLabel(value), text)); -test("mount checks once, Check again refreshes, and neither request asks for authentication", async () => { - const calls: string[] = []; - globalThis.fetch = (async (url, init) => { - calls.push(String(url)); - assert.equal(init?.cache, "no-store"); - assert.equal(new Headers(init?.headers).get("x-learnharness-claude-code"), "1"); - return Response.json(calls.length === 1 ? status("not-found") : status("meets-minimum", "2.1.310")); - }) as typeof fetch; - await mount(); - assert.equal(label(), "Not found"); - await act(async () => button().click()); - assert.equal(label(), "Version 2.1.310 · meets minimum"); - assert.deepEqual(calls, ["/api/claude-code", "/api/claude-code"]); +test("enabled mount checks once; outlined Check again refreshes without probing authentication", async () => { + let probes = 0; + const calls = fixture(async () => Response.json(++probes === 1 ? status("not-found") : status("meets-minimum", "2.1.310"))); + await mount(); assert.equal(label(), "Not found"); + await act(async () => button().click()); assert.equal(label(), "Version 2.1.310 · meets minimum"); + assert.equal(calls.filter((call) => call === "GET /api/claude-code").length, 2); }); -test("slow checks disable recheck and unmount aborts outstanding work", async () => { +test("failed opt-in save stays collapsed/off and never probes", async () => { + const calls = fixture(async () => assert.fail("must not probe"), false, true); + await mount(); await act(async () => toggle().click()); + assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!button()); + assert.match(document.querySelector('[role="alert"]')?.textContent ?? "", /Could not save/); + assert(!calls.includes("GET /api/claude-code")); +}); + +test("slow checks disable recheck; switching off aborts pending version work without starting another probe", async () => { let signal: AbortSignal | null | undefined; let resolve!: (response: Response) => void; - globalThis.fetch = ((_url, init) => { signal = init?.signal; return new Promise((done) => { resolve = done; }); }) as typeof fetch; - await mount(); - assert.equal(label(), "Checking…"); assert(button().disabled); - await act(async () => { root!.unmount(); root = undefined; }); - assert(signal?.aborted); - await act(async () => resolve(Response.json(status("not-found")))); - assert.equal(document.querySelector('[role="status"]'), null); + fixture((init) => { signal = init?.signal; return new Promise((done) => { resolve = done; }); }); + await mount(); assert.equal(label(), "Checking…"); assert(button().disabled); assert(!toggle().disabled); + await act(async () => toggle().click()); assert(signal?.aborted); assert(!button()); + await act(async () => resolve(Response.json(status("meets-minimum", "2.1.310")))); assert.equal(label(), undefined); }); for (const failure of ["network", "http", "payload"] as const) test(`${failure} failure never retains a stale meets-minimum status`, async () => { let calls = 0; - globalThis.fetch = (async () => { + fixture(async () => { if (++calls === 1) return Response.json(status("meets-minimum", "2.1.310")); if (failure === "network") throw new Error("offline"); return failure === "http" ? new Response(null, { status: 503 }) : Response.json({ state: "authenticated" }); - }) as typeof fetch; + }); await mount(); assert.equal(label(), "Version 2.1.310 · meets minimum"); - await act(async () => button().click()); - assert.equal(label(), "Version could not be checked"); assert(!button().disabled); + await act(async () => button().click()); assert.equal(label(), "Version could not be checked"); assert(!button().disabled); +}); + +function CatalogReader() { + const { catalog } = useModelCatalog(); + return {catalog?.models.map((m) => `${m.provider}:${m.id}`).join(",") ?? "loading"}; +} +test("persisted toggle updates invalidate shared model availability in both directions", async () => { + fixture(async () => Response.json(status("not-found")), false); + await mount(); + const models = () => document.querySelector('[data-testid="cli-catalog-models"]')?.textContent; + assert.equal(models(), ""); + await act(async () => toggle().click()); assert.equal(models(), "claude-code:sonnet"); + await act(async () => toggle().click()); assert.equal(models(), ""); }); diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index 9af9c7d..da2242c 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -1,9 +1,12 @@ 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 { after, describe, it } from "node:test"; import assert from "node:assert/strict"; -import { +const testData = fs.mkdtempSync(path.join(os.tmpdir(), "lh-cli-catalog-data-")); +process.env.LH_DATA_DIR = testData; +after(() => fs.rmSync(testData, { recursive: true, force: true })); +const { catalogContextLimits, catalogDefaultsFor, catalogProviders, @@ -16,17 +19,18 @@ import { toModelDto, validateChatSettings, validateModelsSettings, -} from "../src/server/model-catalog.ts"; +} = await import("../src/server/model-catalog.ts"); import type { EffectiveSlot } from "../src/core/settings-resolution.ts"; import type { SettingsValidation } from "../src/server/model-catalog.ts"; import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai"; import { DEFAULT_THINKING_ORDER } from "../src/core/settings.ts"; -import { piEngineForSpec, piModels } from "../src/agent/models.ts"; +const { piEngineForSpec, piModels } = await import("../src/agent/models.ts"); import { BACKGROUND_MODEL_ROLES } from "../src/core/settings.ts"; import { resolveSettings } from "../src/core/settings-resolution.ts"; -import { readSettings } from "../src/store/settings.ts"; -import { effectiveSettings } from "../src/server/stores.ts"; -import { providerKeyPresent } from "../src/server/keys.ts"; +const { readSettings, writeSettings } = await import("../src/store/settings.ts"); +const { SETTINGS_FILE } = await import("../config.ts"); +const { effectiveSettings } = await import("../src/server/stores.ts"); +const { providerKeyPresent } = await import("../src/server/keys.ts"); // The model-control wave (2026-09-12): pi-ai owns the registry — these pins // hold our projection + validation honest against the shipped catalog, and @@ -337,9 +341,11 @@ describe("pi thinking truth (pinned against pi-ai upgrades)", () => { }); /** Runs fn with PATH and HOME pointed at a temp dir, so the CLI lookup only * finds a claude binary when installed is true. */ -function withClaudeInstalled(installed: boolean, fn: () => void): void { +function withClaudeInstalled(installed: boolean, fn: () => void, enabled = true): void { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-catalog-")); const saved = { PATH: process.env.PATH, HOME: process.env.HOME, npm_config_prefix: process.env.npm_config_prefix }; + const settings = readSettings(); + writeSettings({ ...(settings ?? { version: 1 }), claudeCode: { enabled } }); if (installed) fs.writeFileSync(path.join(dir, "claude"), "#!/bin/sh\n", { mode: 0o755 }); process.env.PATH = dir; @@ -353,6 +359,8 @@ function withClaudeInstalled(installed: boolean, fn: () => void): void { if (value === undefined) delete process.env[name]; else process.env[name] = value; } + if (settings) writeSettings(settings); + else fs.rmSync(SETTINGS_FILE, { force: true }); fs.rmSync(dir, { recursive: true, force: true }); } } @@ -397,8 +405,14 @@ describe("the claude-code provider (native CLI authentication, no LearnHarness k assert.equal(engine.model.id, "sonnet"); }); - it('providerKeyPresent("claude-code") follows executable presence, not a generic keyless default', () => { + it('providerKeyPresent("claude-code") requires both explicit opt-in and executable presence', () => { withClaudeInstalled(true, () => assert.equal(providerKeyPresent("claude-code"), true)); withClaudeInstalled(false, () => assert.equal(providerKeyPresent("claude-code"), false)); + withClaudeInstalled(true, () => { + assert.equal(providerKeyPresent("claude-code"), false); + assert.equal(catalogProviders().find((p) => p.id === "claude-code")?.keyPresent, false); + assert(!modelCatalog().models.some((m) => m.provider === "claude-code")); + assert.equal(validateChatSettings({ model: { provider: "claude-code", id: "sonnet" } }).ok, false); + }, false); }); }); diff --git a/tests/settings-body.test.ts b/tests/settings-body.test.ts index 16c0ac9..7c7df16 100644 --- a/tests/settings-body.test.ts +++ b/tests/settings-body.test.ts @@ -239,7 +239,7 @@ describe("SETTINGS_SECTIONS (the route's section map)", () => { // threshold is engine behavior, not an agent-section key. assert.deepEqual( Object.keys(SETTINGS_SECTIONS), - ["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation"], + ["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation", "claudeCode"], ); }); }); From f69fa354cc9663c00bad48b61ccbdf173ab82286 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 21:52:48 +0200 Subject: [PATCH 78/87] fix: reserve native Claude Code provider namespace --- src/server/custom-providers.ts | 3 ++- tests/custom-providers.test.ts | 47 ++++++++++++++++++++++++++++++---- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/server/custom-providers.ts b/src/server/custom-providers.ts index c597403..d78342e 100644 --- a/src/server/custom-providers.ts +++ b/src/server/custom-providers.ts @@ -6,9 +6,10 @@ import { createCustomProvider, customProviderApis, customProviderAuth } from ".. import { configureProviderSource } from "../agent/models.ts"; import { parseCustomProviders, resolveCustomValue, CustomProviderConfigError, type CustomProviderConfig } from "../core/custom-providers.ts"; import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts"; +import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts"; import { readCustomProvidersFile } from "../store/custom-providers.ts"; -const reserved = new Set([...builtinProviders().map((p) => p.id), "chatgpt", ...NATIVE_SUBSCRIPTIONS.map((p) => p.logicalId)]); +const reserved = new Set([...builtinProviders().map((p) => p.id), "chatgpt", CLAUDE_CODE_PROVIDER, ...NATIVE_SUBSCRIPTIONS.map((p) => p.logicalId)]); /** Instance-local so every Next server module graph registers into its own Pi * collection. No process-global registry or personal Pi config is consulted. */ diff --git a/tests/custom-providers.test.ts b/tests/custom-providers.test.ts index 71e9aa3..baf2080 100644 --- a/tests/custom-providers.test.ts +++ b/tests/custom-providers.test.ts @@ -7,6 +7,7 @@ import { createModels, normalizeContext, type SimpleStreamOptions } from "@earen import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { parseCustomProviders, resolveCustomValue } from "../src/core/custom-providers.ts"; import { createCustomProvider, customProviderApis } from "../src/agent/custom-providers.ts"; +import { CLAUDE_CODE_PROVIDER } from "../src/core/contracts/claude-code.ts"; const zero = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; function model(id = "chat", extra = {}) { @@ -27,7 +28,7 @@ const { modelCatalog, validateModelsSettings, clampModelsLevels, catalogContextL const { piModels, piEngineForSpec } = await import("../src/agent/models.ts"); const { providerKeyPresent, keyValue } = await import("../src/server/keys.ts"); const { authStore } = await import("../src/store/auth.ts"); -const { CustomProviderSource, installCustomProviderSource } = await import("../src/server/custom-providers.ts"); +const { CustomProviderSource, customProviderConfigured, installCustomProviderSource } = await import("../src/server/custom-providers.ts"); const { callCost } = await import("../src/server/pricing.ts"); after(() => fs.rmSync(temp, { recursive: true, force: true })); @@ -270,15 +271,51 @@ describe("custom providers in the existing application wiring", () => { assert.equal(previous.apiKey, "one"); assert.equal(previous.headers!["x-shared"], "model-first"); }); - it("rejects built-in and subscription-route collisions without replacing their providers", () => { - for (const id of ["openai", "github-copilot", "chatgpt", "github-copilot-subscription", "xai-subscription"]) { + it("rejects built-in, subscription and native CLI collisions without replacing their providers", () => { + for (const id of ["openai", "github-copilot", "chatgpt", "github-copilot-subscription", "xai-subscription", CLAUDE_CODE_PROVIDER]) { const existing = piModels().getProvider("openai")!; - const models = createModels(); models.setProvider(existing); + const nativeCli = piModels().getProvider(CLAUDE_CODE_PROVIDER)!; + const models = createModels(); models.setProvider(existing); models.setProvider(nativeCli); const source = new CustomProviderSource(() => ({ text: JSON.stringify({ providers: { [id]: config().providers.gateway } }) }), () => undefined); source.sync(models); assert.ok(source.getError()); assert.equal(models.getProvider("openai"), existing); - assert.equal(models.getProviders().length, 1); + assert.equal(models.getProvider(CLAUDE_CODE_PROVIDER), nativeCli); + assert.equal(models.getProviders().length, 2); + } + }); + it("cannot bypass CLI opt-in or replace/remove the native provider across custom configuration reloads", (t) => { + const nativeCli = piModels().getProvider(CLAUDE_CODE_PROVIDER)!; + assert.ok(nativeCli); + const aliases = piModels().getModels(CLAUDE_CODE_PROVIDER); + const savedConfig = fs.existsSync(CUSTOM_MODELS_FILE) ? fs.readFileSync(CUSTOM_MODELS_FILE, "utf8") : undefined; + const savedSettings = fs.existsSync(SETTINGS_FILE) ? fs.readFileSync(SETTINGS_FILE, "utf8") : undefined; + t.after(() => { + if (savedConfig === undefined) fs.rmSync(CUSTOM_MODELS_FILE, { force: true }); else fs.writeFileSync(CUSTOM_MODELS_FILE, savedConfig); + if (savedSettings === undefined) fs.rmSync(SETTINGS_FILE, { force: true }); else fs.writeFileSync(SETTINGS_FILE, savedSettings); + piModels(); + }); + fs.writeFileSync(SETTINGS_FILE, JSON.stringify({ version: 1, claudeCode: { enabled: false } })); + const collision = { providers: { [CLAUDE_CODE_PROVIDER]: config().providers.gateway } }; + for (const [value, error] of [ + [collision, true], // Initial collision must never enter registered/ready. + [config(), false], // A normal custom route still works. + [collision, true], // Invalid reload retires custom routes, not native ones. + ["malformed configuration", true], + [undefined, false], // Removing the file must not delete the native CLI. + [config(), false], + [undefined, false], + ] as const) { + if (value === undefined) fs.rmSync(CUSTOM_MODELS_FILE, { force: true }); + else if (typeof value === "string") fs.writeFileSync(CUSTOM_MODELS_FILE, value); + else save(value); + const catalog = modelCatalog(); + assert.equal(Boolean(catalog.customProvidersError), error); + assert.equal(customProviderConfigured(CLAUDE_CODE_PROVIDER), undefined); + assert.equal(providerKeyPresent(CLAUDE_CODE_PROVIDER), false, "custom auth must never bypass disabled CLI admission"); + assert.equal(piModels().getProvider(CLAUDE_CODE_PROVIDER), nativeCli, "preserve the native provider identity, not a reconstructed substitute"); + assert.deepEqual(piModels().getModels(CLAUDE_CODE_PROVIDER), aliases); + assert.equal(catalog.models.some((m) => m.provider === CLAUDE_CODE_PROVIDER), false); } }); it("fails closed across partial publication and recovers without leaking internal errors", () => { From b4a13f5c3b8e1cab861e0a7574ef49d2fefb1fab Mon Sep 17 00:00:00 2001 From: iamabhishekt Date: Wed, 28 Oct 2026 16:16:30 -0400 Subject: [PATCH 79/87] feat: manual model IDs when /models is unavailable Hosted OpenAI-compatible APIs often serve chat but not GET /models (Volcengine Ark Agent Plan returns 404), so connect failed. Accept a typed list of model IDs: merged into discovered models when /models works, used alone when it does not. The typed list is stored as customProvider.manualModels so reconnects and pin saves keep it. --- app/api/custom-provider/route.ts | 19 +++++++++----- components/settings/keys-section.tsx | 18 ++++++++++++- src/core/settings-resolution.ts | 3 ++- src/core/settings.ts | 9 +++++++ src/server/custom-provider.ts | 6 +++++ tests/custom-endpoint.test.ts | 39 +++++++++++++++++++++++++++- 6 files changed, 84 insertions(+), 10 deletions(-) diff --git a/app/api/custom-provider/route.ts b/app/api/custom-provider/route.ts index 193dd2c..5280609 100644 --- a/app/api/custom-provider/route.ts +++ b/app/api/custom-provider/route.ts @@ -16,13 +16,14 @@ import { discoverModels, effectiveSettings, keyValue, + mergeManualModels, parseCustomModelOverridesBody, readSettingsRecord, saveKey, syncCustomProvider, writeSettingsRecord, } from "@/src/server"; -import { CUSTOM_PROVIDER, normalizeBaseUrl, sanitizeCustomModelOverrides } from "@/src/core"; +import { CUSTOM_PROVIDER, normalizeBaseUrl, parseModelIds, sanitizeCustomModelOverrides } from "@/src/core"; export const dynamic = "force-dynamic"; @@ -32,12 +33,13 @@ export async function GET() { baseUrl: cp.baseUrl, models: cp.models, overrides: cp.overrides, + manualModels: cp.manualModels, keyPresent: keyValue(CUSTOM_PROVIDER.envVar) !== undefined, }); } export async function POST(request: Request) { - const body = (await request.json().catch(() => null)) as { baseUrl?: unknown; key?: unknown } | null; + const body = (await request.json().catch(() => null)) as { baseUrl?: unknown; key?: unknown; manualModels?: unknown } | null; const base = normalizeBaseUrl(typeof body?.baseUrl === "string" ? body.baseUrl : ""); if (!base) return Response.json({ error: "Base URL must be a valid http(s) URL" }, { status: 400 }); @@ -45,9 +47,12 @@ export async function POST(request: Request) { const keyProvided = typeof body?.key === "string"; const key = keyProvided ? (body!.key as string).trim() : undefined; - // Probe first: a URL that doesn't answer with a model list never persists. + const manualModels = parseModelIds(body?.manualModels); const found = await discoverModels(base, key ? key : undefined); - if (!found.ok) return Response.json({ error: found.error }, { status: 502 }); + if (!found.ok && !manualModels.length) { + return Response.json({ error: `${found.error}. If this endpoint has no /models list, add model IDs manually.` }, { status: 502 }); + } + const models = mergeManualModels(found.ok ? found.models : [], manualModels); if (keyProvided) { if (key) { @@ -63,11 +68,11 @@ export async function POST(request: Request) { // endpoint still advertises (the single prune law, core/settings.ts). const overrides = sanitizeCustomModelOverrides( record.customProvider?.overrides, - found.models.map((m) => m.id), + models.map((m) => m.id), ); const next = writeSettingsRecord({ ...record, - customProvider: { baseUrl: base, models: found.models, overrides }, + customProvider: { baseUrl: base, models, overrides, ...(manualModels.length ? { manualModels } : {}) }, }); syncCustomProvider(next.customProvider); return Response.json({ ok: true, models: next.customProvider?.models ?? [] }); @@ -89,7 +94,7 @@ export async function PATCH(request: Request) { const record = readSettingsRecord() ?? { version: 1 }; const next = writeSettingsRecord({ ...record, - customProvider: { baseUrl: cp.baseUrl, models: cp.models, overrides }, + customProvider: { baseUrl: cp.baseUrl, models: cp.models, overrides, ...(cp.manualModels.length ? { manualModels: cp.manualModels } : {}) }, }); syncCustomProvider(next.customProvider); return Response.json({ ok: true, overrides: next.customProvider?.overrides ?? {} }); diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 5aac78d..95a848f 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -59,6 +59,7 @@ interface CustomStatus { models: CustomModel[]; /** User pins by model id (context / max output / thinking). */ overrides: Record; + manualModels: string[]; keyPresent: boolean; } @@ -413,6 +414,7 @@ export function KeysSection({ baseUrl: cp.baseUrl ?? "", models: cp.models ?? [], overrides: cp.overrides ?? {}, + manualModels: cp.manualModels ?? [], keyPresent: Boolean(cp.keyPresent), }); } catch { @@ -513,6 +515,7 @@ export function KeysSection({ keyPresent={custom?.keyPresent ?? false} models={custom?.models ?? []} overrides={custom?.overrides ?? {}} + manualModels0={custom?.manualModels ?? []} onSaved={async () => { setAddingCustom(false); await reload(); @@ -728,6 +731,7 @@ function CustomEndpointEditor({ keyPresent, models, overrides, + manualModels0, onSaved, onPinsSaved, onCancel, @@ -739,6 +743,7 @@ function CustomEndpointEditor({ models: readonly CustomModel[]; /** Their stored user pins. */ overrides: Record; + manualModels0: readonly string[]; onSaved: () => void | Promise; /** Pins saved — the editor stays open (a form, not a connect). */ onPinsSaved: () => void | Promise; @@ -748,6 +753,7 @@ function CustomEndpointEditor({ const [baseUrl, setBaseUrl] = useState(baseUrl0); const [key, setKey] = useState(""); const [keyTouched, setKeyTouched] = useState(false); + const [manualModels, setManualModels] = useState(manualModels0.join(", ")); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // The limits form: edited drafts over the stored pins, plus the last saved @@ -797,7 +803,7 @@ function CustomEndpointEditor({ const res = await fetch("/api/custom-provider", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ baseUrl, ...(keyTouched ? { key } : {}) }), + body: JSON.stringify({ baseUrl, manualModels, ...(keyTouched ? { key } : {}) }), }).catch(() => null); setBusy(false); if (!res || !res.ok) { @@ -824,6 +830,16 @@ function CustomEndpointEditor({ className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" />
+ setManualModels(e.target.value)} + placeholder="Model IDs, comma-separated — optional, needed when the endpoint has no /models list" + aria-label="Custom endpoint model IDs" + data-testid="custom-models-input" + disabled={busy} + className="h-7 min-w-0 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" + />
; /** Custom endpoint, resolved concrete: baseUrl defaults "", models and * user pins default to empty. */ - customProvider: { baseUrl: string; models: CustomModel[]; overrides: Record }; + customProvider: { baseUrl: string; models: CustomModel[]; overrides: Record; manualModels: string[] }; } /** The conversation pair's chain (BYOK, owner 2026-09-22): the chat's own @@ -230,6 +230,7 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings baseUrl: record?.customProvider?.baseUrl ?? "", models: record?.customProvider?.models ?? [], overrides: record?.customProvider?.overrides ?? {}, + manualModels: record?.customProvider?.manualModels ?? [], }, }; } diff --git a/src/core/settings.ts b/src/core/settings.ts index 8e5d638..18b3e1d 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -259,6 +259,13 @@ export interface CustomProviderSettings { /** User pins keyed by model id — pure user intent, never rewritten by * discovery, pruned to the endpoint's live ids on connect. */ overrides?: Record; + manualModels?: string[]; +} + +export function parseModelIds(raw: unknown): string[] { + const items = typeof raw === "string" ? raw.split(/[\s,]+/) : Array.isArray(raw) ? raw : []; + const ids = items.filter((v): v is string => typeof v === "string").map((v) => v.trim()).filter(Boolean); + return [...new Set(ids)]; } /** Canonical base-URL form (single source): trims, requires absolute http(s), @@ -644,6 +651,8 @@ function sanitizeCustomProvider(raw: Record): CustomProviderSet } const overrides = sanitizeCustomModelOverrides(raw.overrides); if (Object.keys(overrides).length) out.overrides = overrides; + const manualModels = parseModelIds(raw.manualModels); + if (manualModels.length) out.manualModels = manualModels; return out; } diff --git a/src/server/custom-provider.ts b/src/server/custom-provider.ts index b9f9b8c..4dc3eb6 100644 --- a/src/server/custom-provider.ts +++ b/src/server/custom-provider.ts @@ -131,6 +131,12 @@ export async function discoverModels(baseUrl: string, apiKey?: string): Promise< } } +export function mergeManualModels(discovered: readonly CustomModel[], manualIds: readonly string[]): CustomModel[] { + const byId = new Map(discovered.map((m) => [m.id, m])); + for (const id of manualIds) if (!byId.has(id)) byId.set(id, { id }); + return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)); +} + /** Model rows from a list response ({data:[…]} | {models:[…]} | bare array), * or null if unrecognized. */ function modelRows(body: unknown): unknown[] | null { diff --git a/tests/custom-endpoint.test.ts b/tests/custom-endpoint.test.ts index 8b64253..d477b79 100644 --- a/tests/custom-endpoint.test.ts +++ b/tests/custom-endpoint.test.ts @@ -15,6 +15,7 @@ import { CUSTOM_MODEL_PIN_FLOOR, customProviderUsable, normalizeBaseUrl, + parseModelIds, sanitizeCustomModelOverrides, sanitizeUserSettings, } from "../src/core/settings.ts"; @@ -24,6 +25,7 @@ import { CUSTOM_PROVIDER } from "../src/core/auth-keys.ts"; import { buildOpenAiCompatibleProvider, discoverModels, + mergeManualModels, syncCustomProvider, } from "../src/server/custom-provider.ts"; import { piModels } from "../src/agent/models.ts"; @@ -160,7 +162,7 @@ describe("parseCustomModelOverridesBody — the strict write side", () => { // ─── resolution ────────────────────────────────────────────────────────── describe("resolveSettings — customProvider", () => { it("resolves an absent section to concrete defaults", () => { - assert.deepEqual(resolveSettings(null).customProvider, { baseUrl: "", models: [], overrides: {} }); + assert.deepEqual(resolveSettings(null).customProvider, { baseUrl: "", models: [], overrides: {}, manualModels: [] }); }); it("carries stored pins through to the concrete view", () => { const resolved = resolveSettings({ @@ -169,6 +171,41 @@ describe("resolveSettings — customProvider", () => { }); assert.deepEqual(resolved.customProvider.overrides, { a: { context: 32768 } }); }); + it("carries stored manual model ids through to the concrete view", () => { + const resolved = resolveSettings({ + version: 1, + customProvider: { baseUrl: "http://h/v1", models: [{ id: "a" }], manualModels: ["a"] }, + }); + assert.deepEqual(resolved.customProvider.manualModels, ["a"]); + }); +}); + +describe("manual model ids", () => { + it("parseModelIds splits a typed list on commas and whitespace, trims and dedupes", () => { + assert.deepEqual(parseModelIds(" doubao-seed-2.1-pro, kimi-k3.1-pro\nqwen/qwen3-max ,, kimi-k3.1-pro "), [ + "doubao-seed-2.1-pro", + "kimi-k3.1-pro", + "qwen/qwen3-max", + ]); + }); + it("parseModelIds accepts a string array and ignores non-strings and junk", () => { + assert.deepEqual(parseModelIds(["a", " b ", "", 3, null, "a"]), ["a", "b"]); + assert.deepEqual(parseModelIds(undefined), []); + assert.deepEqual(parseModelIds({ id: "a" }), []); + }); + it("the sanitizer keeps a clean manual list and drops an empty one", () => { + const kept = sanitizeUserSettings({ version: 1, customProvider: { baseUrl: "http://h/v1", manualModels: [" a ", "a", "b"] } }); + assert.deepEqual(kept?.customProvider?.manualModels, ["a", "b"]); + const dropped = sanitizeUserSettings({ version: 1, customProvider: { baseUrl: "http://h/v1", manualModels: [] } }); + assert.equal(dropped?.customProvider?.manualModels, undefined); + }); + it("mergeManualModels adds typed ids with defaults and never replaces discovered metadata", () => { + const merged = mergeManualModels([{ id: "b", image: true, context: 65536 }], ["c", "b", "a"]); + assert.deepEqual(merged, [{ id: "a" }, { id: "b", image: true, context: 65536 }, { id: "c" }]); + }); + it("mergeManualModels alone builds the list when discovery found nothing", () => { + assert.deepEqual(mergeManualModels([], ["x"]), [{ id: "x" }]); + }); }); // ─── provider build + live sync ────────────────────────────────────────────── From ef37c26433eecbf22138c897f85b59765caceb03 Mon Sep 17 00:00:00 2001 From: d-buckner <51932404+d-buckner@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:56:53 -0700 Subject: [PATCH 80/87] fix: never lose custom endpoint models or pins on a failed probe A connect that fell back to typed model ids replaced the stored model list with the typed ids alone, then pruned the user pins against it. A transient /models failure (503, timeout) therefore deleted every discovered model, its detected metadata, and its pins, and answered 200. - DiscoverResult carries the endpoint's HTTP status. A 401/403 fails the connect even when ids are typed: a rejected key is not a missing model list. - A failed probe keeps the stored snapshot for the SAME base URL, so the prune cannot delete pins for a probe that failed. A new base URL starts clean, so an older endpoint's models never follow the URL. - Comment corrections: the route contract and the CustomProviderSettings fields described the behaviour from before the manual-ids change. - tests/custom-provider-route.test.ts covers the four branches: transient failure, rejected key, new endpoint with no model list, working probe. tsc and eslint clean. Suite: 2554 tests, 2 failures, both also failing on 6959482 (render-sidebar date drift, subscription-auth install artifact). --- app/api/custom-provider/route.ts | 39 ++++++--- src/core/settings.ts | 9 +- src/server/custom-provider.ts | 13 ++- tests/custom-provider-route.test.ts | 129 ++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 15 deletions(-) create mode 100644 tests/custom-provider-route.test.ts diff --git a/app/api/custom-provider/route.ts b/app/api/custom-provider/route.ts index 5280609..f77ad78 100644 --- a/app/api/custom-provider/route.ts +++ b/app/api/custom-provider/route.ts @@ -1,11 +1,17 @@ /** * The custom endpoint's connect surface (one provider in the Keys list). - * GET → non-secret config { baseUrl, models, overrides } + keyPresent (masked). - * POST → connect: validate + normalize the base URL, probe /models (fail - * fast), store the key, persist { baseUrl, models }, re-sync the - * provider into piModels(). Discovery lives here because connecting - * and discovering are one user action. Stored user pins survive a - * reconnect, pruned to the ids the endpoint still advertises. + * GET → non-secret config { baseUrl, models, overrides, manualModels } + + * keyPresent (masked). + * POST → connect: validate + normalize the base URL, probe /models, store + * the key, persist { baseUrl, models, overrides, manualModels }, + * re-sync the provider into piModels(). Discovery lives here because + * connecting and discovering are one user action. A failed probe is + * fatal UNLESS the user typed model IDs: an endpoint that serves chat + * but no model list is configured from those ids alone. A failed + * probe for the endpoint already stored keeps its last snapshot, so + * a blip cannot prune the stored pins. A 401/403 always fails: a + * rejected key is not a missing model list. Stored user pins survive + * a reconnect, pruned to the ids the connect ends up offering. * PATCH → store per-model pins only. No probe: pins must be editable with * the local server switched off (the case they exist for). * DELETE → disconnect: drop the stored config + the stored key and unregister @@ -48,11 +54,25 @@ export async function POST(request: Request) { const key = keyProvided ? (body!.key as string).trim() : undefined; const manualModels = parseModelIds(body?.manualModels); + const record = readSettingsRecord() ?? { version: 1 }; const found = await discoverModels(base, key ? key : undefined); + // A rejected key is not a missing model list: never let typed ids turn an + // auth failure into a stored "connected" endpoint. + if (!found.ok && (found.status === 401 || found.status === 403)) { + return Response.json({ error: found.error }, { status: 502 }); + } if (!found.ok && !manualModels.length) { return Response.json({ error: `${found.error}. If this endpoint has no /models list, add model IDs manually.` }, { status: 502 }); } - const models = mergeManualModels(found.ok ? found.models : [], manualModels); + // A failed probe says nothing about what the endpoint offers: keep the last + // snapshot of the SAME endpoint, so a transient failure cannot shrink the + // model list and prune its pins. A different base URL starts clean. + const discovered = found.ok + ? found.models + : record.customProvider?.baseUrl === base + ? (record.customProvider?.models ?? []) + : []; + const models = mergeManualModels(discovered, manualModels); if (keyProvided) { if (key) { @@ -63,9 +83,8 @@ export async function POST(request: Request) { } } - const record = readSettingsRecord() ?? { version: 1 }; - // User pins are never rewritten by discovery — only pruned to what the - // endpoint still advertises (the single prune law, core/settings.ts). + // User pins are never rewritten by discovery — only pruned to the ids this + // connect offers (discovered ∪ typed). const overrides = sanitizeCustomModelOverrides( record.customProvider?.overrides, models.map((m) => m.id), diff --git a/src/core/settings.ts b/src/core/settings.ts index 18b3e1d..22bf9d9 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -254,11 +254,16 @@ export interface CustomModelOverride { export interface CustomProviderSettings { /** Normalized base URL (see normalizeBaseUrl). Empty/absent = not configured. */ baseUrl?: string; - /** Models discovered from the endpoint (refreshed on connect). */ + /** Models this endpoint offers: advertised ids merged with the typed + * manual ones (rewritten on connect; a failed probe keeps the same + * endpoint's last snapshot). */ models?: CustomModel[]; /** User pins keyed by model id — pure user intent, never rewritten by - * discovery, pruned to the endpoint's live ids on connect. */ + * discovery, pruned on connect to the ids that connect offers. */ overrides?: Record; + /** Model ids the user typed by hand, for an endpoint that publishes no + * usable /models list. Kept apart from the snapshot so a reconnect can + * re-merge them after discovery refreshes. */ manualModels?: string[]; } diff --git a/src/server/custom-provider.ts b/src/server/custom-provider.ts index 4dc3eb6..2d947e6 100644 --- a/src/server/custom-provider.ts +++ b/src/server/custom-provider.ts @@ -101,8 +101,12 @@ export function syncCustomProvider(cp: CustomProviderSettings | undefined): void ); } -/** Outcome of a model-discovery probe. */ -export type DiscoverResult = { ok: true; models: CustomModel[] } | { ok: false; error: string }; +/** Outcome of a model-discovery probe. `status` is the endpoint's HTTP status + * when it answered at all, so callers can tell a rejected key from a missing + * model list. */ +export type DiscoverResult = + | { ok: true; models: CustomModel[] } + | { ok: false; error: string; status?: number }; /** Probe GET {baseUrl}/models for model ids. Scheme validated (http/https * only); keyless endpoints answer without a bearer. Non-2xx or unparseable @@ -118,7 +122,7 @@ export async function discoverModels(baseUrl: string, apiKey?: string): Promise< headers: key ? { authorization: `Bearer ${key}` } : {}, signal: controller.signal, }); - if (!res.ok) return { ok: false, error: `Endpoint returned ${res.status} ${res.statusText}` }; + if (!res.ok) return { ok: false, error: `Endpoint returned ${res.status} ${res.statusText}`, status: res.status }; const body = (await res.json().catch(() => null)) as unknown; const list = extractModels(body); if (list === null) return { ok: false, error: "Endpoint response was not a model list" }; @@ -131,6 +135,9 @@ export async function discoverModels(baseUrl: string, apiKey?: string): Promise< } } +/** The connect's model list: what the endpoint advertises, plus every typed id + * it does not advertise (at default limits, so the pins can correct them). + * Discovered metadata always wins over a typed id of the same name. */ export function mergeManualModels(discovered: readonly CustomModel[], manualIds: readonly string[]): CustomModel[] { const byId = new Map(discovered.map((m) => [m.id, m])); for (const id of manualIds) if (!byId.has(id)) byId.set(id, { id }); diff --git a/tests/custom-provider-route.test.ts b/tests/custom-provider-route.test.ts new file mode 100644 index 0000000..6315ded --- /dev/null +++ b/tests/custom-provider-route.test.ts @@ -0,0 +1,129 @@ +/** + * POST /api/custom-provider — the connect policy around manually typed model + * ids. Two rules earn a route test (the pure pieces live in + * custom-endpoint.test.ts): + * 1. Typed ids make a FAILED probe survivable, but never a rejected key. + * 2. A failed probe never shrinks the snapshot it already stored: that prune + * would take the model metadata and the user's pins with it. + */ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { after, before, describe, it } from "node:test"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-route-")); +process.env.LH_DATA_DIR = dir; +process.env.LH_AUTH_FILE = path.join(dir, "auth.json"); +const SETTINGS_FILE = path.join(dir, "settings.json"); + +const realFetch = globalThis.fetch; +after(() => { + globalThis.fetch = realFetch; + fs.rmSync(dir, { recursive: true, force: true }); + delete process.env.LH_DATA_DIR; + delete process.env.LH_AUTH_FILE; +}); + +type Route = typeof import("../app/api/custom-provider/route.ts"); +let route: Route; +before(async () => { + route = await import("../app/api/custom-provider/route.ts"); +}); + +const BASE = "http://h/v1"; +/** A connected endpoint with two discovered models, a pin on each, one typed id. */ +const CONNECTED = { + version: 1, + customProvider: { + baseUrl: BASE, + models: [{ id: "disc-1", context: 131072 }, { id: "disc-2" }], + overrides: { "disc-1": { context: 200000 }, "disc-2": { maxTokens: 8192 } }, + manualModels: ["m1"], + }, +}; + +function seed(record: unknown): void { + fs.writeFileSync(SETTINGS_FILE, JSON.stringify(record, null, "\t"), "utf-8"); +} + +function stored(): { customProvider?: { models?: { id: string }[]; overrides?: unknown; manualModels?: string[] } } { + return JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8")); +} + +function answerWith(status: number, statusText: string, body?: unknown): void { + globalThis.fetch = (async () => (body === undefined ? new Response("nope", { status, statusText }) : Response.json(body))) as typeof fetch; +} + +function post(payload: unknown): Promise { + return route.POST( + new Request("http://localhost/api/custom-provider", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }), + ); +} + +describe("POST /api/custom-provider — typed ids and a failed probe", () => { + it("a transient failure keeps the stored models, their metadata, and their pins", async () => { + seed(CONNECTED); + answerWith(503, "Service Unavailable"); + + const res = await post({ baseUrl: BASE, manualModels: "m1" }); + assert.equal(res.status, 200); + + const cp = stored().customProvider; + assert.deepEqual(cp?.models?.map((m) => m.id), ["disc-1", "disc-2", "m1"]); + assert.deepEqual(cp?.models?.[0], { id: "disc-1", context: 131072 }, "detected metadata survives"); + assert.deepEqual(cp?.overrides, CONNECTED.customProvider.overrides, "the prune must not fire on a failed probe"); + assert.deepEqual(cp?.manualModels, ["m1"]); + }); + + it("a rejected key fails the connect even when ids are typed", async () => { + seed(CONNECTED); + answerWith(401, "Unauthorized"); + const before = stored(); + + const res = await post({ baseUrl: BASE, key: "sk-abcdefghijk", manualModels: "m1" }); + assert.equal(res.status, 502); + assert.deepEqual(stored(), before, "nothing is stored on an auth failure"); + }); + + it("a new endpoint with no model list starts from the typed ids alone", async () => { + seed(CONNECTED); + answerWith(404, "Not Found"); + + const res = await post({ baseUrl: "http://other/v1", manualModels: "y, x" }); + assert.equal(res.status, 200); + + const cp = stored().customProvider; + assert.deepEqual(cp?.models?.map((m) => m.id), ["x", "y"], "the previous endpoint's models do not follow the URL"); + assert.equal(cp?.overrides, undefined, "no pin survives a list the old pins do not belong to"); + assert.deepEqual(cp?.manualModels, ["y", "x"], "the typed order is kept as typed"); + }); + + it("no model list and no typed ids still refuses, with the hint", async () => { + answerWith(404, "Not Found"); + + const res = await post({ baseUrl: BASE }); + assert.equal(res.status, 502); + const body = (await res.json()) as { error: string }; + assert.match(body.error, /404/); + assert.match(body.error, /add model IDs manually/); + }); + + it("a working probe merges the typed ids and keeps discovered metadata", async () => { + seed(CONNECTED); + answerWith(200, "OK", { data: [{ id: "disc-1", context_length: 65536 }] }); + + const res = await post({ baseUrl: BASE, manualModels: "disc-1, m2" }); + assert.equal(res.status, 200); + + const cp = stored().customProvider; + assert.deepEqual(cp?.models?.map((m) => m.id), ["disc-1", "m2"]); + assert.deepEqual(cp?.models?.[0], { id: "disc-1", context: 65536 }, "discovery is not replaced by a bare typed id"); + assert.deepEqual(cp?.overrides, { "disc-1": { context: 200000 } }, "pins follow the ids this connect offers"); + assert.deepEqual(cp?.manualModels, ["disc-1", "m2"]); + }); +}); From 3c76930964dcdd54fc056d07069eb7c342862be6 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 23:21:15 +0200 Subject: [PATCH 81/87] fix: preserve math through streaming HTML repair Disable only Remend's incomplete-HTML stripping in message/tool content and reasoning rendering. Keep TeX, source data, and sanitization unchanged; unfinished HTML can display literally. PR #266 identified the less-than comparison trigger. This uses an independently authored renderer configuration fix rather than its TeX-spacing workaround, with streaming, incremental, literal-code, and safety regressions. --- components/ai-elements/message.tsx | 7 ++ components/ai-elements/reasoning.tsx | 4 +- tests/render-streaming-math.test.tsx | 140 +++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 tests/render-streaming-math.test.tsx diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx index 9e22382..dbfa7a2 100644 --- a/components/ai-elements/message.tsx +++ b/components/ai-elements/message.tsx @@ -355,6 +355,12 @@ export const streamdownPlugins = { math: createMathPlugin({ singleDollarTextMath: true }), } as unknown as PluginConfig; +// Remend's incomplete-HTML heuristic mistakes ` { // Legacy backslash delimiters are converted in a single linear scan; @@ -375,6 +381,7 @@ export const MessageResponse = memo( )} components={mdComponents} plugins={streamdownPlugins} + remend={streamdownRemend} {...props} > {content} diff --git a/components/ai-elements/reasoning.tsx b/components/ai-elements/reasoning.tsx index 3fe0d1c..c938b80 100644 --- a/components/ai-elements/reasoning.tsx +++ b/components/ai-elements/reasoning.tsx @@ -24,7 +24,7 @@ import { import { Streamdown } from "streamdown"; import { transformMathDelimiters } from "@/src/core"; -import { streamdownPlugins } from "./message"; +import { streamdownPlugins, streamdownRemend } from "./message"; import { mdComponents } from "@/components/md-blocks"; import { Shimmer } from "./shimmer"; @@ -209,7 +209,7 @@ export const ReasoningContent = memo( )} {...props} > - + {transformMathDelimiters(children)} diff --git a/tests/render-streaming-math.test.tsx b/tests/render-streaming-math.test.tsx new file mode 100644 index 0000000..f1ba9eb --- /dev/null +++ b/tests/render-streaming-math.test.tsx @@ -0,0 +1,140 @@ +import React, { act } from "react"; +(globalThis as { React?: typeof React }).React = React; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { createRoot } from "react-dom/client"; +import { parseHTML } from "linkedom"; +import { MessageResponse, ToolContentResponse } from "../components/ai-elements/message.tsx"; +import { Reasoning, ReasoningContent } from "../components/ai-elements/reasoning.tsx"; +import { transformMathDelimiters } from "../src/core/math-delims.ts"; + +const surfaces = { + message: (source: string) => {source}, + tool: (source: string) => {source}, + reasoning: (source: string) => {source}, +}; +const documentFor = (element: React.ReactElement) => + parseHTML(`${renderToStaticMarkup(element)}`).document; +const annotations = (root: ParentNode) => [...root.querySelectorAll('annotation[encoding="application/x-tex"]')].map(node => node.textContent); + +// PR266 identified the less-than + letter trigger. Test the actual streaming +// render seam, not a TeX-space workaround: valid TeX must remain byte-identical. +for (const [name, surface] of Object.entries(surfaces)) describe(`streaming math — ${name}`, () => { + for (const [source, tex] of [ + ["$a { + const document = documentFor(surface(`${source}\n\nTAIL`)); + assert.deepEqual(annotations(document), [tex]); + assert.ok(document.body.textContent.includes("TAIL")); + assert.ok(!document.body.textContent.includes("[blocked]")); + assert.equal(document.querySelectorAll(".katex-error").length, 0); + }); + + it("retains probability brackets, both formulas, and the following question", () => { + const source = String.raw`$F_X(2)=0.60$이라고 하자. $P[-1 { + for (const source of ["$a { + for (const source of ["`$a { + const partial = documentFor(surface("before bold TAIL")); + assert.equal(complete.querySelector("b")?.textContent, "bold"); + assert.equal(complete.body.textContent, "before bold TAIL"); + }); + + it("keeps the other incomplete-Markdown repairs", () => { + const bold = documentFor(surface("**bold TAIL")); + assert.equal(bold.querySelector("strong")?.textContent, "bold TAIL"); + const link = documentFor(surface("[partial TAIL")); + assert.ok(link.body.textContent.includes("partial TAIL")); + assert.ok(link.body.textContent.includes("[blocked]"), "the incomplete-link placeholder is still hardened"); + }); + + it("still removes script/event attributes and blocks javascript links", () => { + const document = documentFor(surface('before bold [unsafe](javascript:alert) TAIL')); + assert.equal(document.querySelectorAll("script, [onclick]").length, 0); + assert.ok(![...document.querySelectorAll("[href]")].some(node => node.getAttribute("href")?.startsWith("javascript:"))); + assert.ok(!document.body.textContent.includes("alert(1)")); + assert.ok(document.body.textContent.includes("TAIL")); + assert.ok(document.body.textContent.includes("[blocked]")); + }); +}); + +it("preserves native TeX source in both streaming and static modes", () => { + const source = String.raw`$a{source}); + const staticDocument = documentFor({source}); + assert.deepEqual(annotations(streaming), ["a node.innerHTML), + [...staticDocument.querySelectorAll(".katex-html")].map(node => node.innerHTML)); +}); + +// Exercise successive updates on the SAME mounted components, rather than +// mistaking independent server renders for incremental streaming coverage. +it("retains less-than prefixes through mounted streaming updates on every entry point", async () => { + const dom = parseHTML(""); + // linkedom has no layout/animation engine; Radix's open reasoning panel + // only needs these inert browser hooks. This test checks text, not layout. + const globals = { window: dom.window, document: dom.document, Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true, + getComputedStyle: () => ({ display: "block", animationName: "none" }), + requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0), + cancelAnimationFrame: (handle: ReturnType) => clearTimeout(handle) }; + const previous = Object.getOwnPropertyDescriptors(globalThis); + Object.assign(globalThis, globals); + try { + for (const [name, surface] of Object.entries(surfaces)) { + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); + try { + for (const source of ["$a", "$a<", "$a root.render(surface(source))); + if (source.includes(" root.unmount()); + host.remove(); + } + } + } finally { + for (const key of Object.keys(globals)) { + if (previous[key]) Object.defineProperty(globalThis, key, previous[key]); + else Reflect.deleteProperty(globalThis, key); + } + } +}); From 6ff492478098af105656e0c65dcb8bc3502ffca6 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 23:31:37 +0200 Subject: [PATCH 82/87] fix: align pending questions by native occurrence --- components/chat.tsx | 21 +++---- components/session-core.ts | 55 +++++++++++-------- components/session.ts | 10 ++-- src/core/native-messages.ts | 38 ++++++++++++- src/core/pending.ts | 40 ++++++++++---- src/core/view.ts | 9 ++- tests/pending-authority.test.ts | 73 ++++++++++++++++++++++++- tests/pending-question-form.test.tsx | 71 ++++++++++++++++++++++++ tests/session-core.test.ts | 82 +++++++++++++++++++++++++++- 9 files changed, 343 insertions(+), 56 deletions(-) create mode 100644 tests/pending-question-form.test.tsx diff --git a/components/chat.tsx b/components/chat.tsx index 657676b..82bb4bd 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -72,7 +72,7 @@ import { CoverageRing } from "@/components/extraction-coverage"; import { Button } from "@/components/ui/button"; import { activeProblemOf, - danglingQuestionOf, + pendingQuestionOf, dismissedAnswer, endsInFailedTurn, findPendingToolCall, @@ -416,6 +416,7 @@ export function Chat({ void dismissQuestion( p.toolCallId, reading ? { ...answer, readingThinking: reading } : answer, + p.key, ); if (busyRef.current) abortInFlightTurn(); return; @@ -447,7 +448,7 @@ export function Chat({ // mount-only by design (turnInFlight read at initial value) }, []); - const pending = findPendingToolCall(messages, danglingQuestionOf(nativeMessages)?.toolCallId ?? null); + const pending = findPendingToolCall(messages, pendingQuestionOf(nativeMessages)); // The abort handler reads the pending question from a ref (the window // listener is mount-scoped; the value it needs is render-scoped). const pendingRef = useRef(pending); @@ -536,7 +537,7 @@ export function Chat({ readingThinkingRef.current = ""; setReadingThinking(""); }, []); - const pendingQuestionId = pending?.toolCallId ?? null; + const pendingQuestionId = pending?.key ?? null; useEffect(() => { // Different calls never share a draft; a failed submission of THIS // call restores the reading channel alongside the form's snapshot. @@ -1678,8 +1679,8 @@ export function Chat({ fold) and NEVER overlaps the composer. */} { if (pending) saveQuestionDraft(pending.toolCallId, { form }); }} + initialQuestionDraft={pending ? questionDraft(pending.key)?.form : undefined} + onSaveQuestionDraft={(form) => { if (pending) saveQuestionDraft(pending.key, { form }); }} practice={activeProblem} compactionRunning={compacting} onSubmitThinking={submitThinking} @@ -1749,11 +1750,11 @@ export function Chat({ // ride the same callback (QuestionAnswerData.dismissed). The // reading-thinking draft rides along (owner 2026-10-03) — the // check answer is its only carrier. - saveQuestionDraft(pending.toolCallId, { readingThinking: readingThinkingRef.current }); + saveQuestionDraft(pending.key, { readingThinking: readingThinkingRef.current }); const reading = readingThinkingRef.current.trim() || undefined; // The pending-id effect clears this only when the question // actually leaves. An offline preflight keeps the draft visible. - submitAnswer(pending.tool, pending.toolCallId, reading ? { ...output, readingThinking: reading } : output); + submitAnswer(pending.tool, pending.toolCallId, reading ? { ...output, readingThinking: reading } : output, pending.key); }} /> @@ -1934,7 +1935,7 @@ function DockOverlay({ setupCta, queuedSteers, }: { - pending: ReturnType; + pending: ReturnType; initialQuestionDraft?: QuestionFormDraft; onSaveQuestionDraft: (draft: QuestionFormDraft) => void; /** Compaction running for this chat (round 14) — the docks' submit @@ -2001,7 +2002,7 @@ function DockOverlay({ const [revealed, setRevealed] = useState(false); const revealPending = requiresReveal && !revealed; - const pendingId = pending?.toolCallId ?? null; + const pendingId = pending?.key ?? null; const initialPendingRef = useRef(pendingId); // Teach-question ARRIVAL state, applied during render — BEFORE the @@ -2064,7 +2065,7 @@ function DockOverlay({ const shellRef = useRef(null); const contentRef = useRef(null); const mode = pending - ? `question:${pending.toolCallId}` + ? `question:${pending.key}` : practice ? `practice:${practice.toolCallId}` : "chat"; diff --git a/components/session-core.ts b/components/session-core.ts index f02aef2..e859a4b 100644 --- a/components/session-core.ts +++ b/components/session-core.ts @@ -17,8 +17,7 @@ import type { AgentEvent } from "@earendil-works/pi-agent-core"; import type { ToolResultMessage } from "@earendil-works/pi-ai"; import { needsContinuation, - danglingQuestionOf, - answeredCallIds, + pendingQuestionOf, failedResponseAtEnd, measuredContextTokens, newAnswerResult, @@ -119,12 +118,13 @@ async function* sseEvents(reader: ReadableStreamDefaultReader): Asyn /** The dangling question call's tool name (the optimistic answer result's * toolName — the server stamps the same from its own record scan). */ export function pendingCallTool(messages: readonly LhMessage[], toolCallId: string): string | null { - for (const m of messages) { - if ((m as { role?: string }).role !== "assistant") continue; - const content = (m as { content?: unknown }).content; - if (!Array.isArray(content)) continue; - for (const block of content as Array<{ type?: string; id?: string; name?: string }>) { - if (block.type === "toolCall" && block.id === toolCallId) return block.name ?? null; + // Match projection's newest-occurrence pairing for pre-uniquifier records. + for (let mi = messages.length - 1; mi >= 0; mi--) { + const m = messages[mi]!; + if (m.role !== "assistant") continue; + for (let bi = m.content.length - 1; bi >= 0; bi--) { + const block = m.content[bi]!; + if (block.type === "toolCall" && block.id === toolCallId) return block.name; } } return null; @@ -644,9 +644,15 @@ export class SessionModel { * POST + consume. Ready-gated: one turn per client at a time (the * server's 409 + identity retry covers the wake-race window). */ - async send(input: ChatInput, onAccepted?: () => void): Promise { + async send(input: ChatInput, onAccepted?: () => void, questionKey?: string): Promise { const recovering = this.statusValue === "error"; if (!this.readyRef && !recovering) return false; + const subject = input.type === "answer" ? pendingQuestionOf([...this.base, ...this.tail]) : null; + // A render-scoped answer must not move to another occurrence sharing + // its id. This identity is local only; the server's wire is unchanged. + if (input.type === "answer" && questionKey !== undefined && + (subject?.key !== questionKey || subject.toolCallId !== input.toolCallId)) return false; + const expectedKey = questionKey ?? (input.type === "answer" && subject?.toolCallId === input.toolCallId ? subject.key : undefined); this.consumeAbort?.abort(); const controller = new AbortController(); this.consumeAbort = controller; @@ -668,7 +674,8 @@ export class SessionModel { await this.resumeStream(false); return false; } - if (input.type === "answer" && (answeredCallIds(record.messages).has(input.toolCallId) || danglingQuestionOf(record.messages)?.toolCallId !== input.toolCallId)) { + const current = pendingQuestionOf(record.messages); + if (current?.toolCallId !== input.toolCallId || (expectedKey !== undefined && current.key !== expectedKey)) { // The call already resolved, or no longer owns the dock. Adopt // truth, never POST that answer a second time. this.setStatus("ready"); @@ -700,7 +707,7 @@ export class SessionModel { const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz"; const result = { ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() }; optimistic.push(result); - this.unconfirmedAnswers.set(result.id, input.toolCallId); + this.unconfirmedAnswers.set(result.id, expectedKey ?? input.toolCallId); } if (optimistic.length > 0) this.mutate(() => { this.tail = [...this.tail, ...optimistic]; @@ -828,9 +835,9 @@ export class SessionModel { /** The answer path (the dock): the answer IS the question call's tool * result (fork 1), riding as its input event. */ - submitAnswer(tool: string, toolCallId: string, output: QuestionAnswerData): Promise { + submitAnswer(tool: string, toolCallId: string, output: QuestionAnswerData, questionKey?: string): Promise { void tool; - return this.send({ type: "answer", toolCallId, answer: output }).then(() => undefined); + return this.send({ type: "answer", toolCallId, answer: output }, undefined, questionKey).then(() => undefined); } /** The dismissal path (owner design 2026-10-03, "A2"): the skipped @@ -839,8 +846,10 @@ export class SessionModel { * user's choice; the next plain send is the wake. On failure the * durable record wins: the error surfaces, the record-changed sync * (or any later refetch) replaces the optimistic append with truth. */ - async dismissQuestion(toolCallId: string, answer: QuestionAnswerData): Promise { + async dismissQuestion(toolCallId: string, answer: QuestionAnswerData, questionKey?: string): Promise { if (!this.readyRef) return; + const subject = pendingQuestionOf([...this.base, ...this.tail]); + if (questionKey !== undefined && (subject?.key !== questionKey || subject.toolCallId !== toolCallId)) return; const tool = pendingCallTool([...this.base, ...this.tail], toolCallId) ?? "quiz"; this.mutate(() => { this.tail = [...this.tail, { ...newAnswerResult(toolCallId, tool, answer), id: localId() }]; @@ -890,19 +899,21 @@ export class SessionModel { } /** Submission snapshots outlive the dock, not the session mount. */ - questionDraft(toolCallId: string): QuestionDraft | undefined { - return this.questionDrafts.get(toolCallId); + questionDraft(questionKey: string): QuestionDraft | undefined { + return this.questionDrafts.get(questionKey); } - saveQuestionDraft(toolCallId: string, draft: Partial): void { - this.questionDrafts.set(toolCallId, { ...this.questionDrafts.get(toolCallId), ...draft }); + saveQuestionDraft(questionKey: string, draft: Partial): void { + this.questionDrafts.set(questionKey, { ...this.questionDrafts.get(questionKey), ...draft }); } private forgetResolvedDrafts(record: readonly LhMessage[]): void { - const answered = answeredCallIds(record); - for (const id of answered) this.questionDrafts.delete(id); - for (const [id, callId] of this.unconfirmedAnswers) { - if (answered.has(callId)) this.unconfirmedAnswers.delete(id); + const pendingKey = pendingQuestionOf(record)?.key; + for (const key of this.questionDrafts.keys()) { + if (key !== pendingKey) this.questionDrafts.delete(key); + } + for (const [id, key] of this.unconfirmedAnswers) { + if (key !== pendingKey) this.unconfirmedAnswers.delete(id); } } diff --git a/components/session.ts b/components/session.ts index 4b1cbb5..294a64b 100644 --- a/components/session.ts +++ b/components/session.ts @@ -106,20 +106,20 @@ export function useChatSession({ chatId, initialMessages, initialError, initiall const continueIfOwed = useCallback((record: readonly LhMessage[]) => void model.continueIfOwed(record), [model]); const steer = useCallback((text: string) => void model.steer(text), [model]); const submitAnswer = useCallback( - (tool: string, toolCallId: string, output: QuestionAnswerData) => - void model.submitAnswer(tool, toolCallId, output), + (tool: string, toolCallId: string, output: QuestionAnswerData, questionKey?: string) => + void model.submitAnswer(tool, toolCallId, output, questionKey), [model], ); const dismissQuestion = useCallback( - (toolCallId: string, answer: QuestionAnswerData) => void model.dismissQuestion(toolCallId, answer), + (toolCallId: string, answer: QuestionAnswerData, questionKey?: string) => void model.dismissQuestion(toolCallId, answer, questionKey), [model], ); const dismissProblem = useCallback( (problemCallId: string) => void model.dismissProblem(problemCallId), [model], ); - const questionDraft = useCallback((toolCallId: string) => model.questionDraft(toolCallId), [model]); - const saveQuestionDraft = useCallback((toolCallId: string, draft: Parameters[1]) => model.saveQuestionDraft(toolCallId, draft), [model]); + const questionDraft = useCallback((questionKey: string) => model.questionDraft(questionKey), [model]); + const saveQuestionDraft = useCallback((questionKey: string, draft: Parameters[1]) => model.saveQuestionDraft(questionKey, draft), [model]); const setMessages = useCallback((record: LhMessage[], turnError?: string) => model.setMessages(record, turnError), [model]); const adoptRecord = useCallback((record: LhMessage[]) => model.adoptRecord(record), [model]); const stop = useCallback(() => model.stop(), [model]); diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index 520fa86..5ac8193 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -35,7 +35,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai"; -import { QUESTION_TOOLS } from "./pending.ts"; +import { QUESTION_TOOLS, type PendingQuestion } from "./pending.ts"; import { CLAUDE_CODE_PROVIDER } from "./contracts/claude-code.ts"; import { CONCLUDE_ACK_TEXT, @@ -673,16 +673,48 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] { export function danglingQuestionOf( messages: readonly LhMessage[], ): { toolCallId: string; tool: string; input: unknown } | null { + const question = questionOccurrenceOf(messages); + return question ? { toolCallId: question.toolCallId, tool: question.tool, input: question.input } : null; +} + +/** The same authority, retaining native boundaries for the client dock. + * Unique call ids are stable even when a live message's local id is replaced + * by its persisted stamp. Only colliding ids need an occurrence-qualified + * draft/reset key; this never changes stored ids or the answer wire. */ +export function pendingQuestionOf(messages: readonly LhMessage[]): PendingQuestion | null { + const question = questionOccurrenceOf(messages); + if (!question) return null; + let occurrences = 0; + for (const message of messages) { + // A streamed c_ assistant can temporarily coexist with its saved + // copy during append-only adoption. New live calls are already + // uniquified; only settled history establishes reuse. + if (message.role !== "assistant" || message.id.startsWith("c_")) continue; + for (const call of message.content) { + if (call.type === "toolCall" && call.id === question.toolCallId) occurrences++; + } + } + return { + ...question, + key: occurrences > 1 ? JSON.stringify([question.messageId, question.contentIndex, question.toolCallId]) : question.toolCallId, + }; +} + +/** Shared native authority: newest assistant only, siblings in call order. */ +function questionOccurrenceOf(messages: readonly LhMessage[]): Omit | null { for (let mi = messages.length - 1; mi >= 0; mi--) { const m = messages[mi]!; if ((m as { role?: string }).role !== "assistant") continue; const assistant = m as AssistantMessage & { id: string }; const answered = resultIdsFrom(messages.slice(mi + 1)); - for (const block of assistant.content) { + for (const [contentIndex, block] of assistant.content.entries()) { if (block.type !== "toolCall") continue; if (!(QUESTION_TOOLS as readonly string[]).includes(block.name)) continue; if (answered.has(block.id)) continue; - return { toolCallId: block.id, tool: block.name, input: block.arguments }; + return { + toolCallId: block.id, tool: block.name, input: block.arguments, + messageId: assistant.id, contentIndex, + }; } return null; // the newest assistant message's questions are all answered } diff --git a/src/core/pending.ts b/src/core/pending.ts index aa9134a..923e1d6 100644 --- a/src/core/pending.ts +++ b/src/core/pending.ts @@ -38,10 +38,18 @@ export interface PendingToolCall { input: unknown; } +/** Client-only identity: projection may group several native assistants. + * Never persisted or sent as part of an answer event. */ +export interface PendingQuestion extends PendingToolCall { + messageId: string; + contentIndex: number; + /** Unique call ids stay stable across live → persisted message stamps. + * Reused ids include their native occurrence instead. */ + key: string; +} + /** Tools that render in the question dock and gate the composer. The - * probe-phase names (probe/recall, owner 2026-10-09) join their teach - * twins — old records keep the teach names; new probe-phase calls carry - * the phase names. */ + * probe-phase names join their teach twins; old records keep teach names. */ export const QUESTION_TOOLS = [ "quiz", "probe", @@ -105,14 +113,20 @@ export function filterEnabledTools(tools: Record, enabled?: Enable return out; } -function findPending(messages: UIMessage[], names: readonly string[], toolCallId?: string): PendingToolCall | null { +function findPending(messages: UIMessage[], names: readonly string[], authority?: string | PendingQuestion): PendingToolCall | null { for (let m = messages.length - 1; m >= 0; m--) { const msg = messages[m]!; for (let p = 0; p < msg.parts.length; p++) { const part = msg.parts[p]!; if (isToolUIPart(part) && part.state === "input-available") { const tool = part.type.slice("tool-".length); - if (names.includes(tool) && (toolCallId === undefined || part.toolCallId === toolCallId)) { + if (!names.includes(tool)) continue; + if (typeof authority === "object") { + if (part.toolCallId !== authority.toolCallId || tool !== authority.tool || + part.sourceMessageId !== authority.messageId || part.sourceContentIndex !== authority.contentIndex) continue; + return authority; + } + if (authority === undefined || part.toolCallId === authority) { return { toolCallId: part.toolCallId as string, tool, input: part.input }; } } @@ -121,12 +135,16 @@ function findPending(messages: UIMessage[], names: readonly string[], toolCallId return null; } -/** Grouped view messages lose native assistant boundaries. When the native - * record supplies an authority, expose only that call after view validation. - * null means no native question is pending; undefined keeps view-only callers. */ -export function findPendingToolCall(messages: UIMessage[], toolCallId?: string | null): PendingToolCall | null { - if (toolCallId === null) return null; - return findPending(messages, QUESTION_TOOLS, toolCallId); +/** Grouped view messages lose native assistant boundaries. Native callers + * supply the exact occurrence, not just an id: its OWN part must have cleared + * validation. Never fall back to an older available question. + * null means no native pending question; undefined keeps view-only callers. + * The string overload is retained for view-only/id-filter consumers. */ +export function findPendingToolCall(messages: UIMessage[], authority: PendingQuestion | null): PendingQuestion | null; +export function findPendingToolCall(messages: UIMessage[], authority?: string | null): PendingToolCall | null; +export function findPendingToolCall(messages: UIMessage[], authority?: string | PendingQuestion | null): PendingToolCall | null { + if (authority === null) return null; + return findPending(messages, QUESTION_TOOLS, authority); } // ─── Turn activity label (2026-09-19, the “Writing a question…” fix) ────── diff --git a/src/core/view.ts b/src/core/view.ts index 166d8d5..74e4c8d 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -52,6 +52,9 @@ export interface LhViewPart extends Record { text?: string; state?: string; toolCallId?: string; + /** Native location, retained across display grouping (client-only). */ + sourceMessageId?: string; + sourceContentIndex?: number; input?: unknown; output?: unknown; rawInput?: unknown; @@ -219,13 +222,17 @@ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState // back to a spinner mid-transcript; live cards belong at the // bottom, and this gate is what puts them there by construction). const streamingMessage = live?.streamingIds.has(m.id) === true; - for (const block of assistant.content) { + for (const [contentIndex, block] of assistant.content.entries()) { if (block.type === "text") { rest.push({ type: "text", id: `${m.id}-t${textN++}`, text: block.text, state: "done" }); } else if (block.type === "thinking") { reasoning.push({ type: "reasoning", id: `${m.id}-r${reasoningN++}`, text: block.thinking, state: "done" }); } else if (block.type === "toolCall") { const part = toolPartOf(block, live, streamingMessage); + if ((QUESTION_TOOLS as readonly string[]).includes(block.name)) { + part.sourceMessageId = m.id; + part.sourceContentIndex = contentIndex; + } if (block.name === SEND_TO_USER_TOOL) { // Failed/aborted generations never execute their calls; // the provider error/abort UI already owns that outcome. diff --git a/tests/pending-authority.test.ts b/tests/pending-authority.test.ts index 633f651..948b29f 100644 --- a/tests/pending-authority.test.ts +++ b/tests/pending-authority.test.ts @@ -1,16 +1,20 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { findPendingToolCall } from "../src/core/pending.ts"; -import { danglingQuestionOf, newAnswerResult, type LhMessage } from "../src/core/native-messages.ts"; +import { danglingQuestionOf, pendingQuestionOf, newAnswerResult, newUserMessage, type LhMessage } from "../src/core/native-messages.ts"; import { inputAppends } from "../src/core/input-events.ts"; import { projectView } from "../src/core/view.ts"; function assistant(id: string, ...calls: string[]): LhMessage { - return { id, role: "assistant", content: calls.map(id => ({ type: "toolCall", id, name: "quiz", arguments: { question: id } })), stopReason: "toolUse", timestamp: 1 } as LhMessage; + return { + id, role: "assistant", content: calls.map(id => ({ type: "toolCall", id, name: "quiz", arguments: { question: id } })), + stopReason: "toolUse", timestamp: 1, api: "openai-completions", provider: "fixture", model: "fixture", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + }; } function pending(messages: LhMessage[], view = projectView(messages)) { - return findPendingToolCall(view, danglingQuestionOf(messages)?.toolCallId ?? null); + return findPendingToolCall(view, pendingQuestionOf(messages)); } test("a later assistant question supersedes an unanswered question in the same projected group", () => { @@ -38,3 +42,66 @@ test("the authoritative question still waits for projected input validation", () } assert.equal(pending(messages, view), null); }); + +// Synthetic compatibility cases, not claims about the affected user's history. +test("reused ids select the newest native question's input, not an older available part", () => { + const old = assistant("old", "call_0"); + const newer = assistant("new", "call_0"); + if (newer.role === "assistant") newer.content.unshift({ type: "text", text: "new teaching" }); + if (newer.role === "assistant" && newer.content[1]?.type === "toolCall") newer.content[1].arguments = { question: "NEW" }; + const messages = [old, newer]; + const question = pending(messages)!; + assert.equal(question.messageId, "new"); + assert.equal(question.contentIndex, 1); + assert.deepEqual(question.input, { question: "NEW" }); + assert.notEqual(question.key, pending([old])!.key); + assert.deepEqual(danglingQuestionOf(messages), { toolCallId: "call_0", tool: "quiz", input: { question: "NEW" } }, "server authority/shape unchanged"); + const answered = inputAppends({ messages }, { type: "answer", toolCallId: question.toolCallId, answer: { kind: "quiz", answers: [] } as never }); + const parts = projectView([...messages, ...answered]).flatMap(m => m.parts).filter(p => p.type === "tool-quiz"); + assert.equal(parts[0]!.state, "input-available", "old transcript card is untouched"); + assert.equal(parts[1]!.state, "output-available", "result still pairs newest-first"); + assert.equal(pending([...messages, ...answered]), null); +}); + +for (const state of ["input-streaming", "output-error", "output-available"]) { + test(`an older available duplicate never bypasses the authoritative part's ${state} state`, () => { + const messages = [assistant("old", "call_0"), assistant("new", "call_0")]; + const view = projectView(messages); + const ownPart = view.flatMap(m => m.parts).find(p => p.sourceMessageId === "new" && p.type === "tool-quiz")!; + ownPart.state = state; + assert.equal(pending(messages, view), null); + }); +} + +test("a missing authoritative part cannot fall back to another occurrence", () => { + const messages = [assistant("old", "call_0"), assistant("new", "call_0")]; + assert.equal(pending(messages, projectView(messages.slice(0, 1))), null); +}); + +test("older questions never revive across display groups or after a text-only newest assistant", () => { + const messages = [assistant("old", "old-q"), newUserMessage("another turn"), assistant("new", "new-q")]; + assert.equal(pending(messages)?.toolCallId, "new-q"); + assert.equal(pending([...messages, newAnswerResult("new-q", "quiz", {} as never)]), null); + const textOnly = { ...assistant("text"), content: [{ type: "text", text: "done" }] } as LhMessage; + assert.equal(pending([...messages, textOnly]), null); +}); + +test("unique question keys survive live-to-persisted message stamps without resetting drafts", () => { + const live = assistant("c_live", "unique-q"); + const persisted = { ...live, id: "persisted" }; + assert.equal(pending([live])!.key, pending([persisted])!.key); + assert.notEqual(pending([live])!.messageId, pending([persisted])!.messageId); +}); + +test("native occurrence checks preserve every question tool and its own live validation gate", () => { + for (const tool of ["quiz", "probe", "ask_user_question", "explain", "recall"]) { + const message = assistant("live", "q"); + if (message.role === "assistant" && message.content[0]?.type === "toolCall") message.content[0].name = tool; + const live = { executions: new Map(), inFlight: true, streamingIds: new Set(["live"]) }; + assert.equal(pending([message], projectView([message], live)), null); + live.executions.set("q", { isError: false }); + assert.equal(pending([message], projectView([message], live))?.tool, tool); + live.executions.set("q", { isError: true }); + assert.equal(pending([message], projectView([message], live)), null); + } +}); diff --git a/tests/pending-question-form.test.tsx b/tests/pending-question-form.test.tsx new file mode 100644 index 0000000..363d6ec --- /dev/null +++ b/tests/pending-question-form.test.tsx @@ -0,0 +1,71 @@ +import React, { act } from "react"; +(globalThis as Record).React = React; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createRoot } from "react-dom/client"; +import { parseHTML } from "linkedom"; +import { QuestionDock } from "../components/question-dock.tsx"; +import { pendingQuestionOf, type LhMessage } from "../src/core/native-messages.ts"; +import type { QuizToolInput } from "../src/core/question-core.ts"; + +const dom = parseHTML(""); +Object.assign(globalThis, { + window: dom.window, document: dom.document, HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, +}); + +function question(id: string, callId: string, text: string): LhMessage { + return { id, role: "assistant", content: [{ type: "toolCall", id: callId, name: "quiz", arguments: { + question: text, options: [{ label: "Yes", value: "yes" }, { label: "No", value: "no" }], correctAnswer: "yes", explanation: "Yes is correct.", + } }], stopReason: "toolUse", timestamp: 1 } as unknown as LhMessage; +} + +// The same keyed-content boundary as Chat's DockOverlay. This exercises the +// actual form state under synthetic DOM, not CSS layout or a live provider. +for (const reusedId of [false, true]) { + test(`question form resets on a newer question (${reusedId ? "reused" : "unique"} call id)`, async () => { + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); + const render = async (messages: LhMessage[]) => { + const pending = pendingQuestionOf(messages)!; + await act(async () => root.render( {}} onSubmit={() => {}} dictationClaim={false} />)); + }; + try { + const old = question("old", "q1", "OLD question"); + await render([old]); + const oldForm = host.querySelector('[data-testid="question-dock"]'); + await act(async () => { host.querySelector('[role="radio"]')!.click(); }); + assert.ok(host.querySelector('[role="radio"][aria-checked="true"]')); + await render([old, question("new", reusedId ? "q1" : "q2", "NEW question")]); + assert.notEqual(host.querySelector('[data-testid="question-dock"]'), oldForm); + assert.equal(host.querySelector('[role="radio"][aria-checked="true"]'), null); + assert.match(host.textContent!, /NEW question/); + assert.doesNotMatch(host.textContent!, /OLD question/); + } finally { await act(async () => root.unmount()); host.remove(); } + }); +} + +test("saving a unique live question preserves its mounted form and selection", async () => { + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); + const live = question("c_live", "unique-q", "Same question"); + const saved = { ...live, id: "saved" }; + const render = async (messages: LhMessage[]) => { + const pending = pendingQuestionOf(messages)!; + await act(async () => root.render( {}} onSubmit={() => {}} dictationClaim={false} />)); + }; + try { + await render([live]); + const form = host.querySelector('[data-testid="question-dock"]'); + await act(async () => { host.querySelector('[role="radio"]')!.click(); }); + // Append-only adoption can briefly retain the live copy beside the saved one. + await render([saved, live]); + await render([saved]); + assert.equal(host.querySelector('[data-testid="question-dock"]'), form); + assert.ok(host.querySelector('[role="radio"][aria-checked="true"]')); + } finally { await act(async () => root.unmount()); host.remove(); } +}); diff --git a/tests/session-core.test.ts b/tests/session-core.test.ts index 0e98e80..efc669c 100644 --- a/tests/session-core.test.ts +++ b/tests/session-core.test.ts @@ -24,7 +24,7 @@ import { afterEach, describe, it } from "node:test"; import { absorbed, pendingCallTool, SessionModel } from "../components/session-core.ts"; import type { ChatInput } from "../src/core/input-events.ts"; import type { LhMessage } from "../src/core/native-messages.ts"; -import { newUserMessage, newAnswerResult, newThinkingMessage, needsContinuation, endsInDismissal } from "../src/core/native-messages.ts"; +import { newUserMessage, newAnswerResult, newThinkingMessage, needsContinuation, endsInDismissal, pendingQuestionOf } from "../src/core/native-messages.ts"; import { endsInFailedTurn, newCompactionMarker, newPlanBlockMessage, projectView } from "../src/core/index.ts"; import { dismissedAnswer } from "../src/core/question-core.ts"; import { buildAnswer, type QuestionData } from "../src/core/question-core.ts"; @@ -1020,6 +1020,86 @@ describe("answer submission recovery", () => { }); } + it("does not mistake an old same-id answer for resolution of the current question during recovery", async () => { + let posts = 0; + const newer = { ...question, id: "new-question" } as LhMessage; + const record = [question, newAnswerResult("q1", "quiz", answer), newer]; + const m = model({ initialMessages: record, fetch: async (url) => { + if (String(url) === "/api/chat") { posts++; return new Response("refused", { status: 500 }); } + return Response.json({ messages: record, turnInFlight: false }); + } }); + const key = pendingQuestionOf(record)!.key; + m.saveQuestionDraft(key, { readingThinking: "NEW reasoning" }); + await m.submitAnswer("quiz", "q1", answer, key); + await m.submitAnswer("quiz", "q1", answer, key); + assert.equal(posts, 2, "an older occurrence's result does not forbid retrying this one"); + assert.equal(m.questionDraft(key)?.readingThinking, "NEW reasoning"); + m.setMessages([...record, newAnswerResult("q1", "quiz", answer)]); + assert.equal(m.questionDraft(key), undefined); + }); + + it("adopts a newer same-id occurrence instead of reposting an older question's answer", async () => { + let posts = 0; + const newer = { ...question, id: "new-question" } as LhMessage; + const m = model({ initialMessages: [question], fetch: async (url) => { + if (String(url) === "/api/chat") { posts++; throw new TypeError("offline"); } + return Response.json({ messages: [question, newer], turnInFlight: false }); + } }); + const key = pendingQuestionOf([question])!.key; + await m.submitAnswer("quiz", "q1", answer, key); + await m.submitAnswer("quiz", "q1", answer, key); + assert.equal(posts, 1); + assert.equal(m.status, "ready"); + assert.equal(pendingQuestionOf(m.getState().nativeMessages)?.messageId, "new-question"); + }); + + it("rejects stale render callbacks locally, including same-id silent dismissals", async () => { + let requests = 0; + const m = model({ initialMessages: [question], fetch: async () => { requests++; return Response.json({ ok: true }); } }); + const oldKey = pendingQuestionOf([question])!.key; + const newer = { ...question, id: "new-question" } as LhMessage; + m.setMessages([question, newer]); + await m.submitAnswer("quiz", "q1", answer, oldKey); + await m.dismissQuestion("q1", answer, oldKey); + assert.equal(requests, 0); + assert.equal(m.getState().nativeMessages.length, 2, "no optimistic answer targets the newer question"); + }); + + it("uses the newest occurrence's tool for optimistic answers and dismissals", async () => { + const older = { ...question, id: "old-ask", content: [{ type: "toolCall", id: "q1", name: "ask_user_question", arguments: {} }] } as LhMessage; + for (const dismiss of [false, true]) { + const m = model({ initialMessages: [older, question], fetch: async () => { throw new TypeError("offline"); } }); + const key = pendingQuestionOf([older, question])!.key; + let optimisticTool: string | undefined; + m.onChange = () => { + const result = m.getState().nativeMessages.find(m => m.role === "toolResult"); + if (result?.role === "toolResult") optimisticTool = result.toolName; + }; + if (dismiss) await m.dismissQuestion("q1", answer, key); + else await m.submitAnswer("quiz", "q1", answer, key); + assert.equal(optimisticTool, "quiz"); + } + }); + + it("does not leak drafts between same-id questions, and preserves unique-id live handover", () => { + const m = model({ initialMessages: [question] }); + const oldKey = pendingQuestionOf([question])!.key; + m.saveQuestionDraft(oldKey, { readingThinking: "old reasoning" }); + const newer = { ...question, id: "new-question" } as LhMessage; + m.setMessages([question, newer]); + const newKey = pendingQuestionOf([question, newer])!.key; + assert.notEqual(oldKey, newKey); + assert.equal(m.questionDraft(newKey), undefined); + assert.equal(m.questionDraft(oldKey), undefined); + + const live = { ...question, id: "c_live" }; + m.setMessages([live]); + const liveKey = pendingQuestionOf([live])!.key; + m.saveQuestionDraft(liveKey, { readingThinking: "live reasoning" }); + m.setMessages([question]); + assert.equal(m.questionDraft(pendingQuestionOf([question])!.key)?.readingThinking, "live reasoning"); + }); + it("adopts a changed pending question instead of sending the stale answer", async () => { let posts = 0; const newer = { ...question, id: "new-question", content: [{ type: "toolCall", id: "q2", name: "quiz", arguments: {} }] } as LhMessage; From 3993122a4e72f06771d45a69e507c6d20d4d1096 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 23:41:24 +0200 Subject: [PATCH 83/87] fix: keep live question placeholders pending client-side --- src/core/native-messages.ts | 14 ++++++- tests/pending-authority.test.ts | 20 +++++++++ tests/pending-question-form.test.tsx | 61 ++++++++++++++++++++++++++++ tests/session-core.test.ts | 22 ++++++++++ 4 files changed, 116 insertions(+), 1 deletion(-) diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index 5ac8193..5ec3a72 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -682,7 +682,10 @@ export function danglingQuestionOf( * by its persisted stamp. Only colliding ids need an occurrence-qualified * draft/reset key; this never changes stored ids or the answer wire. */ export function pendingQuestionOf(messages: readonly LhMessage[]): PendingQuestion | null { - const question = questionOccurrenceOf(messages); + // turn_end includes the temporary questionResult acknowledgement. The + // view ignores it too: it is NOT the learner's answer. Exclude it only + // from client authority, leaving persisted/server result policy intact. + const question = questionOccurrenceOf(messages.filter(message => !isLiveQuestionPlaceholder(message))); if (!question) return null; let occurrences = 0; for (const message of messages) { @@ -700,6 +703,15 @@ export function pendingQuestionOf(messages: readonly LhMessage[]): PendingQuesti }; } +/** Only the transient c_ result with the questionResult contract qualifies. + * Real answers have details; errors/aborts remain resolving results. A saved + * null result is not reinterpreted by this client-only compatibility seam. */ +function isLiveQuestionPlaceholder(message: LhMessage): boolean { + return message.role === "toolResult" && message.id.startsWith("c_") && + !message.isError && message.details === null && + (QUESTION_TOOLS as readonly string[]).includes(message.toolName); +} + /** Shared native authority: newest assistant only, siblings in call order. */ function questionOccurrenceOf(messages: readonly LhMessage[]): Omit | null { for (let mi = messages.length - 1; mi >= 0; mi--) { diff --git a/tests/pending-authority.test.ts b/tests/pending-authority.test.ts index 948b29f..bec5f06 100644 --- a/tests/pending-authority.test.ts +++ b/tests/pending-authority.test.ts @@ -93,6 +93,26 @@ test("unique question keys survive live-to-persisted message stamps without rese assert.notEqual(pending([live])!.messageId, pending([persisted])!.messageId); }); +test("only temporary successful question placeholders are excluded from client pending authority", () => { + const message = assistant("new", "q"); + const placeholder = { ...newAnswerResult("q", "quiz", {} as never), id: "c_placeholder", details: null }; + for (const tool of ["quiz", "probe", "ask_user_question", "explain", "recall"]) { + const own = { ...placeholder, toolName: tool }; + assert.equal(pendingQuestionOf([message, own])?.toolCallId, "q"); + assert.equal(danglingQuestionOf([message, own]), null, "server/persisted policy does not change"); + } + for (const result of [ + { ...placeholder, id: "persisted-null-result" }, + { ...placeholder, details: undefined }, + { ...placeholder, isError: true }, + { ...placeholder, isError: true, content: [{ type: "text" as const, text: "Operation aborted" }] }, + { ...placeholder, toolName: "visual" }, + { ...newAnswerResult("q", "quiz", { kind: "quiz", answers: [], dontKnow: true } as never), id: "c_answer" }, + ]) { + assert.equal(pendingQuestionOf([message, result]), null, "real answers, persisted results and failures still resolve the call"); + } +}); + test("native occurrence checks preserve every question tool and its own live validation gate", () => { for (const tool of ["quiz", "probe", "ask_user_question", "explain", "recall"]) { const message = assistant("live", "q"); diff --git a/tests/pending-question-form.test.tsx b/tests/pending-question-form.test.tsx index 363d6ec..9ddc8bd 100644 --- a/tests/pending-question-form.test.tsx +++ b/tests/pending-question-form.test.tsx @@ -5,6 +5,8 @@ import { test } from "node:test"; import { createRoot } from "react-dom/client"; import { parseHTML } from "linkedom"; import { QuestionDock } from "../components/question-dock.tsx"; +import { SessionModel } from "../components/session-core.ts"; +import { findPendingToolCall } from "../src/core/pending.ts"; import { pendingQuestionOf, type LhMessage } from "../src/core/native-messages.ts"; import type { QuizToolInput } from "../src/core/question-core.ts"; @@ -47,6 +49,65 @@ for (const reusedId of [false, true]) { }); } +test("live question validation → turn_end placeholder → canonical sync keeps the same form and typed reasoning", async () => { + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); + const encoder = new TextEncoder(); + let wire!: ReadableStreamDefaultController; + const body = new ReadableStream({ start(controller) { wire = controller; } }); + const model = new SessionModel({ chatId: "fixture", initialMessages: [], fetch: async () => new Response(body) }); + let submitted: { thinking?: string } | undefined; + const render = () => { + const state = model.getState(); + const pending = findPendingToolCall(state.messages, pendingQuestionOf(state.nativeMessages)); + root.render(pending ? {}} onSubmit={answer => { submitted = answer; }} dictationClaim={false} /> : null); + }; + model.onChange = render; + const event = async (value: unknown) => { + await act(async () => { wire.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`)); }); + }; + let consume: Promise | undefined; + try { + await act(async () => { consume = model.resumeStream(false); }); + const saved = question("saved", "q1", "Streamed question"); + const { id: _savedId, ...message } = saved; + await event({ type: "message_start", message }); + await event({ type: "message_end", message }); + assert.equal(host.querySelector('[data-testid="question-dock"]'), null, "validation still gates the form"); + await event({ type: "tool_execution_end", toolCallId: "q1", toolName: "quiz", isError: false, + result: { content: [{ type: "text", text: "Question posed" }], details: null } }); + const form = host.querySelector('[data-testid="question-dock"]'); + assert.ok(form); + const occurrence = pendingQuestionOf(model.getState().nativeMessages)!; + await act(async () => { + host.querySelector('[role="radio"]')!.click(); + const editor = host.querySelector('[data-testid="thinking-field"] [data-file-path-input]')!; + editor.textContent = "Keep my typed reasoning"; + editor.dispatchEvent(new window.Event("input", { bubbles: true })); + }); + await event({ type: "turn_end", message, toolResults: [{ role: "toolResult", toolCallId: "q1", toolName: "quiz", + content: [{ type: "text", text: "Question posed" }], details: null, isError: false, timestamp: 1 }] }); + assert.ok(host.querySelector('[data-testid="question-dock"]') === form, "placeholder must never unmount the form"); + assert.deepEqual(pendingQuestionOf(model.getState().nativeMessages), occurrence, "exact native occurrence stays pending"); + await act(async () => { model.setMessages([saved]); }); + assert.ok(host.querySelector('[data-testid="question-dock"]') === form, "canonical save must not reset the form either"); + assert.ok(host.querySelector('[role="radio"][aria-checked="true"]')); + assert.equal(host.querySelector('[data-testid="thinking-field"] [data-file-path-input]')!.textContent, "Keep my typed reasoning"); + await act(async () => { host.querySelector('form')!.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true })); }); + assert.equal(submitted?.thinking, "Keep my typed reasoning", "the React form state, not only DOM text, survived"); + } finally { + await act(async () => { + wire.enqueue(encoder.encode("data: [DONE]\n\n")); wire.close(); + await consume; + root.unmount(); + }); + model.onChange = undefined; + host.remove(); + } +}); + test("saving a unique live question preserves its mounted form and selection", async () => { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); diff --git a/tests/session-core.test.ts b/tests/session-core.test.ts index efc669c..20209e1 100644 --- a/tests/session-core.test.ts +++ b/tests/session-core.test.ts @@ -1020,6 +1020,28 @@ describe("answer submission recovery", () => { }); } + it("accepts immediate/recovery submissions and dismissals while a live question placeholder is in the tail", async () => { + for (const dismiss of [false, true]) { + let posts = 0; + const m = model({ initialMessages: [question], fetch: async (url) => { + if (String(url) === "/api/chat" || String(url).endsWith("/dismiss")) { + posts++; + return new Response("refused", { status: 500 }); + } + return Response.json({ messages: [question], turnInFlight: false }); + } }); + m.applyWireEvent({ type: "turn_end", message: question, toolResults: [{ role: "toolResult", toolCallId: "q1", toolName: "quiz", + content: [{ type: "text", text: "Question posed" }], details: null, isError: false, timestamp: 1 }] }); + const key = pendingQuestionOf([question])!.key; + if (dismiss) await m.dismissQuestion("q1", answer, key); + else { + await m.submitAnswer("quiz", "q1", answer, key); + await m.submitAnswer("quiz", "q1", answer, key); + } + assert.equal(posts, dismiss ? 1 : 2, "placeholder must not reject the render's question identity"); + } + }); + it("does not mistake an old same-id answer for resolution of the current question during recovery", async () => { let posts = 0; const newer = { ...question, id: "new-question" } as LhMessage; From b48821309b7b63e98fc5425cf000fce94d73be1b Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Thu, 29 Oct 2026 04:43:53 +0200 Subject: [PATCH 84/87] feat: finish donor-led custom endpoint integration --- app/api/custom-provider/route.ts | 137 +-------- components/settings/keys-section.tsx | 148 ++++++--- components/setup-dialog.tsx | 43 +-- config.ts | 3 - docs/custom-providers.md | 252 +++++++--------- scripts/smoke-provider-runtime.mjs | 8 + src/agent/custom-providers.ts | 71 ----- src/agent/models.ts | 21 +- src/core/contracts/models.ts | 2 - src/core/custom-provider-auth.ts | 26 ++ src/core/custom-providers.ts | 146 --------- src/core/settings.ts | 22 +- src/server/custom-provider-auth.ts | 43 +++ src/server/custom-provider-body.ts | 31 ++ src/server/custom-provider-http.ts | 149 ++++++++++ src/server/custom-provider.ts | 84 ++++-- src/server/custom-providers.ts | 91 ------ src/server/index.ts | 1 + src/server/keys.ts | 32 +- src/server/model-catalog.ts | 8 +- src/server/pricing.ts | 3 +- src/server/settings-body.ts | 4 +- src/server/stores.ts | 2 +- src/store/auth.ts | 47 ++- src/store/custom-providers.ts | 12 - tests/custom-endpoint.test.ts | 13 +- tests/custom-provider-boundaries.test.ts | 191 ++++++++++++ tests/custom-provider-lifecycle.test.ts | 129 ++++++++ tests/custom-provider-restart.test.ts | 81 +++++ tests/custom-provider-route.test.ts | 5 +- tests/custom-providers.test.ts | 362 ----------------------- tests/helpers/custom-provider-auth.ts | 10 + tests/helpers/custom-provider-crash.ts | 56 ++++ 33 files changed, 1116 insertions(+), 1117 deletions(-) delete mode 100644 src/agent/custom-providers.ts create mode 100644 src/core/custom-provider-auth.ts delete mode 100644 src/core/custom-providers.ts create mode 100644 src/server/custom-provider-auth.ts create mode 100644 src/server/custom-provider-body.ts create mode 100644 src/server/custom-provider-http.ts delete mode 100644 src/server/custom-providers.ts delete mode 100644 src/store/custom-providers.ts create mode 100644 tests/custom-provider-boundaries.test.ts create mode 100644 tests/custom-provider-lifecycle.test.ts create mode 100644 tests/custom-provider-restart.test.ts delete mode 100644 tests/custom-providers.test.ts create mode 100644 tests/helpers/custom-provider-auth.ts create mode 100644 tests/helpers/custom-provider-crash.ts diff --git a/app/api/custom-provider/route.ts b/app/api/custom-provider/route.ts index f77ad78..54b267c 100644 --- a/app/api/custom-provider/route.ts +++ b/app/api/custom-provider/route.ts @@ -1,132 +1,9 @@ -/** - * The custom endpoint's connect surface (one provider in the Keys list). - * GET → non-secret config { baseUrl, models, overrides, manualModels } + - * keyPresent (masked). - * POST → connect: validate + normalize the base URL, probe /models, store - * the key, persist { baseUrl, models, overrides, manualModels }, - * re-sync the provider into piModels(). Discovery lives here because - * connecting and discovering are one user action. A failed probe is - * fatal UNLESS the user typed model IDs: an endpoint that serves chat - * but no model list is configured from those ids alone. A failed - * probe for the endpoint already stored keeps its last snapshot, so - * a blip cannot prune the stored pins. A 401/403 always fails: a - * rejected key is not a missing model list. Stored user pins survive - * a reconnect, pruned to the ids the connect ends up offering. - * PATCH → store per-model pins only. No probe: pins must be editable with - * the local server switched off (the case they exist for). - * DELETE → disconnect: drop the stored config + the stored key and unregister - * the provider (the mirror image of POST; idempotent). - */ -import { - deleteKey, - discoverModels, - effectiveSettings, - keyValue, - mergeManualModels, - parseCustomModelOverridesBody, - readSettingsRecord, - saveKey, - syncCustomProvider, - writeSettingsRecord, -} from "@/src/server"; -import { CUSTOM_PROVIDER, normalizeBaseUrl, parseModelIds, sanitizeCustomModelOverrides } from "@/src/core"; +/** PR #62's single custom endpoint: discover/manual connect, offline pins, + * and disconnect. Host boundaries and mutations live behind the server barrel. */ +import { customProviderGet, customProviderMutation } from "@/src/server"; export const dynamic = "force-dynamic"; - -export async function GET() { - const cp = effectiveSettings().customProvider; - return Response.json({ - baseUrl: cp.baseUrl, - models: cp.models, - overrides: cp.overrides, - manualModels: cp.manualModels, - keyPresent: keyValue(CUSTOM_PROVIDER.envVar) !== undefined, - }); -} - -export async function POST(request: Request) { - const body = (await request.json().catch(() => null)) as { baseUrl?: unknown; key?: unknown; manualModels?: unknown } | null; - const base = normalizeBaseUrl(typeof body?.baseUrl === "string" ? body.baseUrl : ""); - if (!base) return Response.json({ error: "Base URL must be a valid http(s) URL" }, { status: 400 }); - - // Empty key string clears it, absent leaves it stored; only a real key rides the probe. - const keyProvided = typeof body?.key === "string"; - const key = keyProvided ? (body!.key as string).trim() : undefined; - - const manualModels = parseModelIds(body?.manualModels); - const record = readSettingsRecord() ?? { version: 1 }; - const found = await discoverModels(base, key ? key : undefined); - // A rejected key is not a missing model list: never let typed ids turn an - // auth failure into a stored "connected" endpoint. - if (!found.ok && (found.status === 401 || found.status === 403)) { - return Response.json({ error: found.error }, { status: 502 }); - } - if (!found.ok && !manualModels.length) { - return Response.json({ error: `${found.error}. If this endpoint has no /models list, add model IDs manually.` }, { status: 502 }); - } - // A failed probe says nothing about what the endpoint offers: keep the last - // snapshot of the SAME endpoint, so a transient failure cannot shrink the - // model list and prune its pins. A different base URL starts clean. - const discovered = found.ok - ? found.models - : record.customProvider?.baseUrl === base - ? (record.customProvider?.models ?? []) - : []; - const models = mergeManualModels(discovered, manualModels); - - if (keyProvided) { - if (key) { - const saved = saveKey(CUSTOM_PROVIDER.envVar, key); - if (!saved.ok) return Response.json({ error: saved.error }, { status: 400 }); - } else { - deleteKey(CUSTOM_PROVIDER.envVar); - } - } - - // User pins are never rewritten by discovery — only pruned to the ids this - // connect offers (discovered ∪ typed). - const overrides = sanitizeCustomModelOverrides( - record.customProvider?.overrides, - models.map((m) => m.id), - ); - const next = writeSettingsRecord({ - ...record, - customProvider: { baseUrl: base, models, overrides, ...(manualModels.length ? { manualModels } : {}) }, - }); - syncCustomProvider(next.customProvider); - return Response.json({ ok: true, models: next.customProvider?.models ?? [] }); -} - -/** Store the per-model pins (replace-whole). Pruned against the STORED model - * list, never a probe — an offline endpoint keeps its pins editable. */ -export async function PATCH(request: Request) { - const parsed = parseCustomModelOverridesBody(await request.json().catch(() => null)); - if (!parsed.ok) return Response.json({ error: parsed.error }, { status: 400 }); - - const cp = effectiveSettings().customProvider; - if (!cp.baseUrl) return Response.json({ error: "No custom endpoint configured" }, { status: 409 }); - - const overrides = sanitizeCustomModelOverrides( - parsed.settings, - cp.models.map((m) => m.id), - ); - const record = readSettingsRecord() ?? { version: 1 }; - const next = writeSettingsRecord({ - ...record, - customProvider: { baseUrl: cp.baseUrl, models: cp.models, overrides, ...(cp.manualModels.length ? { manualModels: cp.manualModels } : {}) }, - }); - syncCustomProvider(next.customProvider); - return Response.json({ ok: true, overrides: next.customProvider?.overrides ?? {} }); -} - -/** Disconnect: forget the endpoint AND its key, then unregister the provider. - * Deleting the section (rather than blanking baseUrl) keeps settings.json the - * same shape as a never-connected install. Idempotent by construction. */ -export async function DELETE() { - const record = readSettingsRecord() ?? { version: 1 }; - const { customProvider: _dropped, ...rest } = record; - const next = writeSettingsRecord(rest); - deleteKey(CUSTOM_PROVIDER.envVar); - syncCustomProvider(next.customProvider); - return Response.json({ ok: true }); -} +export const GET = customProviderGet; +export const POST = customProviderMutation; +export const PATCH = customProviderMutation; +export const DELETE = customProviderMutation; diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index a3ddbee..219c3c5 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -29,6 +29,7 @@ import { CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW, CUSTOM_MODEL_DEFAULT_MAX_TOKENS, CUSTOM_PROVIDER, + normalizeBaseUrl, type AuthKeyStatus, type CustomModel, type CustomModelOverride, @@ -56,6 +57,10 @@ interface KeysResponse { /** The custom endpoint's non-secret status (GET /api/custom-provider). */ interface CustomStatus { + keyReuseRequired: boolean; + revision: string; + authMode: "keyless" | "apiKey"; + keySource: "env" | "file" | "none"; baseUrl: string; models: CustomModel[]; /** User pins by model id (context / max output / thinking). */ @@ -64,6 +69,13 @@ interface CustomStatus { keyPresent: boolean; } +async function readCustomStatus(inspectKey = false): Promise { + const response = await fetch(`/api/custom-provider${inspectKey ? "?authMode=apiKey" : ""}`); + if (!response.ok) throw new Error("Unable to load custom configuration."); + const cp = await response.json() as Partial; + return { keyReuseRequired: Boolean(cp.keyReuseRequired), revision: response.headers.get("etag") ?? "", authMode: cp.authMode ?? "keyless", keySource: cp.keySource ?? "none", baseUrl: cp.baseUrl ?? "", models: cp.models ?? [], overrides: cp.overrides ?? {}, manualModels: cp.manualModels ?? [], keyPresent: Boolean(cp.keyPresent) }; +} + /** The curated tools group, pinned by id (the catalog owns the copy; * these ids are the contract). Derived from the catalog (search wave, * 2026-09-20: the Google CSE pair retired, Tavily + Brave joined) — the @@ -410,14 +422,7 @@ export function KeysSection({ // Best-effort and independent: the custom endpoint's status must never // blank the provider-key list above if its fetch fails. try { - const cp = (await fetch("/api/custom-provider").then((r) => r.json())) as Partial; - setCustom({ - baseUrl: cp.baseUrl ?? "", - models: cp.models ?? [], - overrides: cp.overrides ?? {}, - manualModels: cp.manualModels ?? [], - keyPresent: Boolean(cp.keyPresent), - }); + setCustom(await readCustomStatus()); } catch { /* custom status unavailable — leave it unset */ } @@ -460,11 +465,13 @@ export function KeysSection({ /** Forget the custom endpoint (config + key server-side), same rhythm as * deleteKey: fire, re-read, broadcast so the catalog narrows everywhere. */ - async function disconnectCustom(): Promise { - await fetch("/api/custom-provider", { method: "DELETE" }).catch(() => undefined); + async function disconnectCustom(revision: string): Promise { + const res = await fetch("/api/custom-provider", { method: "DELETE", headers: { "content-type": "application/json", "if-match": revision }, body: JSON.stringify({}) }).catch(() => null); + if (!res?.ok) return res ? ((await res.json().catch(() => ({}))).error ?? "Disconnect failed.") : "Disconnect failed. Check your connection."; setAddingCustom(false); await reload(); notifySettingsSaved("keys"); + return null; } if (loading || statuses === null) { @@ -512,6 +519,11 @@ export function KeysSection({ )} {addingCustom && ( { await reload(); setAddingCustom(false); }} baseUrl0={custom?.baseUrl ?? ""} keyPresent={custom?.keyPresent ?? false} models={custom?.models ?? []} @@ -658,6 +670,20 @@ function AddProviderEditor({ ); } +/** One donor editor for first setup and Settings; setup omits offline pin and + * disconnect controls, while authentication/revisions/manual IDs stay identical. */ +export function CustomEndpointSetup({ onSaved }: { onSaved: () => void | Promise }) { + const [status, setStatus] = useState(null); + const [error, setError] = useState(false); + async function reload() { + try { setStatus(await readCustomStatus()); setError(false); } + catch { setError(true); } + } + useEffect(() => { void reload(); }, []); + if (!status || error) return
{error ? "Unable to load custom configuration." : "Loading custom configuration…"}{error && }
; + return ; +} + /** The custom endpoint's status row (shown once configured); clicking re-opens the editor. */ function CustomEndpointRow({ status, @@ -669,7 +695,7 @@ function CustomEndpointRow({ onToggle: () => void; }) { const count = status.models.length; - const line = `${status.baseUrl} · ${count} model${count === 1 ? "" : "s"}${status.keyPresent ? "" : " · keyless"}`; + const line = `${status.baseUrl} · ${count} model${count === 1 ? "" : "s"} · ${status.authMode === "keyless" ? "keyless" : status.keyPresent ? "API key" : "API key missing"}`; return (
)}
- {models.length > 0 && ( + {!setup && models.length > 0 && (
- Model limits — blank uses the endpoint’s value, then the default + Budget pins do not resize backend context. Blank uses reported limits, then fallback (8192 context / 4096 output). Custom pricing is unknown.
{models.map((m) => { @@ -904,6 +968,7 @@ function CustomEndpointEditor({
{m.id} + Context: {d.context.trim() ? "user pin" : m.context ? `reported ${m.context}` : `fallback ${CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW}`} · Output: {d.maxTokens.trim() ? "user pin" : m.maxTokens ? `reported ${m.maxTokens}` : `fallback ${CUSTOM_MODEL_DEFAULT_MAX_TOKENS}`} setDraft(m, { reasoning: e.target.value as PinDraft["reasoning"] })} - aria-label={`${m.id} thinking`} + aria-label={`${m.id} reasoning support`} data-testid={pinTestid("custom-pin-reasoning", m.id)} disabled={pinsBusy} className="h-7 shrink-0 rounded-sm border border-border bg-background px-1 font-ui text-[11px] text-foreground outline-none focus:border-muted-foreground/50" > - - - + + +
); @@ -961,11 +1026,8 @@ function CustomEndpointEditor({
)} - {needsKey && ( -

- A key is required to enable chatting — local servers (Ollama, LM Studio) accept any value. -

- )} +

Reasoning support is a capability declaration. The effort picker’s Off may not disable a custom backend’s default thinking.

+ {(error || pinsError) && } {error && (

{error} diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx index 98b6ad7..20e389b 100644 --- a/components/setup-dialog.tsx +++ b/components/setup-dialog.tsx @@ -48,7 +48,7 @@ import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-sig import { useModelCatalog } from "@/components/model-catalog"; import { savedSlotsConfigured } from "@/components/setup-cta"; import { Hint } from "@/components/ui/hint"; -import { type ProviderKeyInfo, CUSTOM_PROVIDER_INFO } from "@/components/settings/keys-section"; +import { type ProviderKeyInfo, CUSTOM_PROVIDER_INFO, CustomEndpointSetup } from "@/components/settings/keys-section"; import { ProviderMenuList } from "@/components/settings/model-menu"; import { ChatGPTBackgroundAccess } from "@/components/settings/chatgpt-connection"; import { NativeSubscriptionBackgroundAccess, SubscriptionConnections } from "@/components/settings/subscription-connections"; @@ -135,8 +135,7 @@ export function SetupKeysBody({ onSave, menuAnchor, isCustom, - baseUrl, - onBaseUrl, + onCustomSaved, }: { /** The full provider roster (GET /api/keys providers) — undefined * while it loads; the picker renders an honest empty state then. */ @@ -157,8 +156,7 @@ export function SetupKeysBody({ /** Chosen provider is the custom endpoint: a base URL field joins the key * field. All three optional so plain-key callers (and tests) stay unchanged. */ isCustom?: boolean; - baseUrl?: string; - onBaseUrl?: (v: string) => void; + onCustomSaved?: () => void; }) { return (

@@ -192,20 +190,8 @@ export function SetupKeysBody({ )}
- {provider && ( + {provider && (isCustom ? : (
- {isCustom && ( - onBaseUrl?.(e.target.value)} - placeholder="Base URL, e.g. http://localhost:11434/v1" - aria-label="Custom endpoint base URL" - data-testid="setup-custom-baseurl" - disabled={saving} - className="h-7 w-full rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" - /> - )}
{ if (e.key === "Enter" && value.trim()) onSave(); }} - placeholder={isCustom ? "API key — required (any value works for local servers)" : "Paste key…"} + placeholder="Paste key…" aria-label={`${provider.label} key`} data-testid="setup-key-input" disabled={saving} @@ -228,11 +214,11 @@ export function SetupKeysBody({ data-testid="setup-key-save" className="shrink-0 rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40" > - {saving ? "Saving…" : isCustom ? "Connect" : "Save key"} + {saving ? "Saving…" : "Save key"}
- )} + ))} {error && (

{error} @@ -286,7 +272,6 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { const [keyProviders, setKeyProviders] = useState(undefined); const [keyProvider, setKeyProvider] = useState(null); const [keyValue, setKeyValue] = useState(""); - const [customBaseUrl, setCustomBaseUrl] = useState(""); const [keySaving, setKeySaving] = useState(false); const [keyError, setKeyError] = useState(null); const [keyMenuOpen, setKeyMenuOpen] = useState(false); @@ -398,18 +383,10 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { * 400s keep their user-facing line inline. */ async function saveKey() { if (!keyProvider || keySaving) return; - const isCustom = keyProvider.id === CUSTOM_PROVIDER.id; if (!keyValue.trim()) return; setKeySaving(true); setKeyError(null); - // Custom endpoint connects through its own route; every other provider is a plain key PUT. - const res = isCustom - ? await fetch("/api/custom-provider", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ baseUrl: customBaseUrl, key: keyValue }), - }).catch(() => null) - : await fetch(`/api/keys/${encodeURIComponent(keyProvider.envVar)}`, { + const res = await fetch(`/api/keys/${encodeURIComponent(keyProvider.envVar)}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ value: keyValue }), @@ -422,7 +399,6 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { } setKeyProvider(null); setKeyValue(""); - setCustomBaseUrl(""); notifySettingsSaved("keys"); } @@ -461,8 +437,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { menuOpen={keyMenuOpen} menuAnchor={keyTriggerRef} isCustom={keyProvider?.id === CUSTOM_PROVIDER.id} - baseUrl={customBaseUrl} - onBaseUrl={setCustomBaseUrl} + onCustomSaved={() => { setKeyProvider(null); notifySettingsSaved("keys"); }} onToggleMenu={() => setKeyMenuOpen((v) => !v)} onPick={(p) => { setKeyProvider(p); setKeyMenuOpen(false); setKeyError(null); }} onValue={setKeyValue} diff --git a/config.ts b/config.ts index daf0b4e..860b571 100644 --- a/config.ts +++ b/config.ts @@ -111,9 +111,6 @@ export const GRAPH_FORMAT_FILE = path.join(PATHS.graph, "format.json"); */ export const SETTINGS_FILE = path.join(DATA_ROOT, "settings.json"); -/** Explicit custom-provider/model definitions; never read the user's Pi profile. */ -export const CUSTOM_MODELS_FILE = path.join(DATA_ROOT, "models.json"); - /** * User prompt-override files (settings-storage wave, 2026-09-19): the * per-segment system-prompt overrides mirroring the SOURCE layout diff --git a/docs/custom-providers.md b/docs/custom-providers.md index bd6901d..eae4abd 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -1,159 +1,133 @@ -# Custom model providers +# Custom and local models -LearnHarness can register several independently named custom providers using its -existing Pi AI runtime. This is configuration wiring, not another agent runtime: -Pi owns the model protocols, request conversion, streaming, tools, thinking and -usage accounting. There is no additional dependency on Pi coding-agent. +LearnHarness supports **one custom OpenAI-compatible endpoint with multiple models**. +The setup and Settings UI are based on PR #62 by Daniel Buckner and Abhishek Thakur. +Pi owns inference, message conversion, tools, streaming, and reasoning-effort handling. -## Configuration location +## Configure an endpoint -Create `models.json` in the LearnHarness app-data directory (the directory -containing `settings.json`; `LH_DATA_DIR` relocates it). LearnHarness does not read -`~/.pi/agent/models.json`, install a model server, or edit this file for you. -Custom-provider setup UI is not included yet. Once configured, models appear in -the existing model selectors when their credential/header references resolve. +1. Open **Settings → Providers → + Add a provider → Custom endpoint**. The same + editor is available from the first-run provider picker. +2. Enter the server's complete API base URL, for example + `http://localhost:11434/v1`. LearnHarness does **not** append `/v1`. +3. Choose **Keyless** or **API key**: + - **Keyless** never uses a saved or environment custom-provider key for + authentication. Pi's OpenAI client receives an internal dummy credential. + - **API key** uses the effective `CUSTOM_OPENAI_API_KEY`. A real environment + value wins over a file-saved value; the editor identifies environment ownership. + Any nonempty trimmed custom key is accepted—there is no arbitrary length floor. + - Changing the URL requires a new effective key or explicit consent to reuse + the existing effective key. A key is never silently forwarded to a new URL. + Clearing authentication selects Keyless. +4. Enter optional **model IDs**, separated by commas or whitespace. This supports + servers whose chat endpoint works but whose `/models` endpoint is absent. +5. Click **Connect**, then choose models for Strong, Cheap, and Vision. -The file uses Pi's `providers` / `models` structure. For example: +HTTP and HTTPS, including localhost/private-network servers, are supported. URLs +with embedded credentials, query strings, or fragments are rejected. Put a key in +the authentication field, not the URL. Discovery redirects are rejected. -```json -{ - "providers": { - "my-local-server": { - "baseUrl": "http://127.0.0.1:11434/v1", - "api": "openai-completions", - "apiKey": "unused-local-key", - "compat": { - "supportsDeveloperRole": false, - "supportsReasoningEffort": false, - "maxTokensField": "max_tokens" - }, - "models": [ - { - "id": "your-installed-model", - "name": "My local model", - "contextWindow": 8192, - "maxTokens": 1024, - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - } - } - ] - }, - "my-gateway": { - "baseUrl": "https://your-gateway.example/v1", - "api": "openai-completions", - "apiKey": "$MY_GATEWAY_API_KEY", - "headers": { "X-Tenant": "${MY_GATEWAY_TENANT}" }, - "models": [ - { - "id": "your-gateway-model-id", - "contextWindow": 32768, - "maxTokens": 4096, - "cost": { - "input": 1, - "output": 3, - "cacheRead": 0, - "cacheWrite": 0 - } - } - ] - } - } -} -``` +Connect requests `GET /models`. Discovery metadata wins over duplicate manually +entered IDs. A failed probe can fall back to entered IDs; a failed probe for the +same URL preserves its previous model snapshot and pins. A different URL starts +with its own models. **401/403 always fails**, even with entered IDs. Response-size, +model-count, and redirect violations also fail explicitly rather than silently +truncating or bypassing a discovery limit. -**Replace all example IDs, limits, prices and compatibility flags with the actual -server/model configuration.** These numbers are examples, not recommendations or -automatically detected capabilities. Rates are USD per million tokens. Explicit -zero rates are allowed; LearnHarness does not assume that custom gateways are -free. Verify the endpoint's billing independently. +Configuration is not a continuous health check. An endpoint may be configured while +offline, and model capability or output quality is not proven by listing its ID. +No backend/model installation, downloads, or OAuth flows are performed. -Use the exact API base URL your server documents. LearnHarness does not append -`/v1`, discover models, infer capabilities from names, or download models. Ollama, -vLLM, LM Studio, 9router and other gateways work only to the extent that their -configured endpoint implements the selected Pi protocol and compatibility flags. -For a keyless OpenAI-compatible local server, supply an explicit nonempty dummy -`apiKey` as above: the native Pi OpenAI client requires a key. Omitting `apiKey` -leaves a provider unavailable rather than implying unauthenticated access. +## Model budgets and reasoning support -## Supported fields +Settings exposes offline-editable pins per model: -Provider fields: `name`, `baseUrl`, `api`, `apiKey`, `headers`, `authHeader`, -`compat`, and the explicit `models` array. Each model requires `id`, -`contextWindow`, `maxTokens`, and `cost` with all four base rates. Unlike Pi -coding-agent's defaults, LearnHarness never invents limits or prices. +- **Context budget**: user pin → reported context → **8192 fallback**. +- **Maximum output**: user pin → reported output limit → **4096 fallback**. +- **Reasoning support**: user declaration → reported support. -Optional Pi model fields are `name`, `api`, `baseUrl`, `reasoning`, `input`, -`thinkingLevelMap`, `inputLimits`, `promptCache`, `samplingParams`, -`samplingParamsByThinkingLevel`, `headers`, and `compat`. Cost tiers and Pi's -exported compatibility schemas are supported. A model's `api` and `baseUrl` -override its provider's values; model headers override provider headers. Names -default to model IDs, `reasoning` defaults to false, and `input` defaults to -`["text"]`. Declare image input only when the server/model supports it. Existing -onboarding and vision-slot requirements are unchanged. +Every positive integer token pin is accepted, including a 512-token output limit. +Blank fields remove that pin. The editor identifies reported, fallback, and user-pin +values. **These are request/compaction budgets; changing them does not resize the +backend's actual context.** Configure the server's active context separately and +enter budgets that match it. Reconnecting prunes pins only to the resulting model +IDs; pin saves do not contact the server. -The native chat transports available in the pinned Pi AI 1.4.0 dependency are: -`openai-completions`, `openai-responses`, `anthropic-messages`, -`google-generative-ai`, `azure-openai-responses`, `google-vertex`, -`bedrock-converse-stream`, `mistral-conversations`, `openai-codex-responses`, and -`pi-messages`. Protocol availability does **not** establish account access, -OAuth support, cloud credentials, subscription permission, or vendor approval. -This feature adds no login flows. In particular, an Anthropic Messages endpoint -is not a Claude Agent SDK or Claude Code runtime. +Reasoning support is a **capability declaration, not an execution toggle**. The +existing Pi-backed effort picker remains available. In particular, **Off may not +disable a custom backend's default thinking**: an absent effort parameter is not a +universal disable command. There is no compatibility/thinking-map editor, Ollama +preset, or model-name inference. Backends requiring additional nonstandard headers +or compatibility configuration are outside this release's custom-endpoint UI. -Use new provider IDs; built-in and subscription-route IDs cannot be replaced. -Provider IDs must be nonempty and cannot contain `:` (the provider/model -coordinate separator). Model IDs must be unique within a provider. Built-in -provider overrides, `modelOverrides`, OAuth definitions, executable plugins, and -automatic discovery are outside this phase. +## Images and Vision -## Credentials and trust +All custom models can be selected for Vision; models that report image support +appear first. The endpoint owner is responsible for choosing an appropriate model. +Built-in model filtering is unchanged. -API keys and header values accept literals, `$NAME`, and `${NAME}`. References -use LearnHarness's existing key lookup: environment first, then the existing -flat `auth.json` file, respecting the fresh-user environment-ignore setting. -There is no auth migration or new secret file format. Prefer references over -putting secrets into `models.json`; protect any file containing literal secrets. -Unknown variable references keep the provider unavailable. All required header -references, including model-specific ones, must resolve before a provider becomes -available. An empty key does not configure a provider. +Missing discovery metadata does not silently remove images: **every custom-engine +image-bearing request attempts delivery**, including teaching `read` results, +rendered PDF pages, and visual-maker screenshots. A request-local model clone permits +Pi's native image encoding; registered/catalog metadata is not promoted to claim +that every model understands images. A text-only backend may reject the request; +that error is surfaced, with no silent text-only fallback. This does not establish +real-model image-understanding quality. -Pi's `$$` and `$!` literal escapes are supported. `!command` credential/header -values are rejected, never executed; resolved environment/file values are data, -not commands. Optional `authHeader: true` adds `Authorization: Bearer ` in -addition to the selected native transport's normal authentication behavior. +## Credentials, reloads, and concurrent editing -Only trusted people should edit this file: its endpoints receive model prompts, -attachments, tool results and configured credentials. Only HTTP(S) URLs are -accepted, without embedded URL credentials or fragments. This is not an -arbitrary-endpoint discovery or config-editing API and does not add a network -sandbox, endpoint reachability checks, or vendor policy exemptions. +Nonsecret configuration is stored in the existing `settings.json.customProvider`; +file-saved credentials use the existing `auth.json` key seam. A reserved internal +string entry, `__learnharness_custom_provider_binding`, binds authorization to the +endpoint, mode, and a random nonsecret `credentialBindingId` in custom settings. +The file key and its binding are published in one existing atomic auth-map write. +The internal entry is not an environment variable or an editable/displayed key. +Neither keys nor raw upstream errors are returned by the custom-provider API. -## Reloading and failures +Every startup and upstream operation requires matching binding IDs, URL, and mode +before using credentials. A crash between auth/settings writes, or a failed +settings write whose auth rollback also fails, leaves the custom provider disabled +rather than sending a new key to an old endpoint. Reload and explicitly reconnect +to repair authorization. A shadowed file key retains its own authorized URL: if an +environment key is reused for another URL and later disappears, the old file key +is not silently forwarded there. Keyless also requires its nonsecret binding, but +never resolves a stored/environment key. Disconnect leaves an internal disabled +marker so a latent environment key cannot revive an interrupted disconnect. -Changes take effect on the next catalog, settings-validation, or inference -operation without restarting. Credential/header reference changes are also -picked up through the existing key lookup. Every upstream model operation -revalidates the same saved provider/model ID against the current Pi catalog, -including later requests inside a long-running agent/tool loop. It uses the -current endpoint, model metadata and resolved credentials. A removed model, -invalid configuration or missing credential blocks the next operation, without -falling back. An HTTP operation already in flight retains its own native Pi -setup and is not implicitly cancelled by a file edit. +Use the custom endpoint editor to change custom credentials; generic +`/api/keys` PUT/DELETE cannot bypass binding. No credential-derived fingerprint is +stored in settings or a public revision. These guards cover process crashes and +restart using the existing atomic rename; they are not a power-loss/fsync +transaction system. Missing auth storage can initialize normally; unreadable or +malformed private storage fails closed without overwriting its contents. -Deleting the file means no custom providers. Invalid JSON, invalid metadata, -reserved IDs, unsupported configuration, or an unreadable file disables the -custom routes, **not** built-in providers. The file accepts Pi's line comments -and trailing commas. `GET /api/models` includes an optional sanitized -`customProvidersError` diagnostic; rendering it in setup/settings UI is deferred. -Correcting the file restores valid providers on the next operation. +Each upstream model operation resolves the current custom model, endpoint, effective +credential, limits, and original saved effort preference against Pi's current model +metadata. Already-started requests keep their setup. A removed model or disconnected +provider fails rather than falling back; saved model selections and history are not +rewritten. -Saved selections and thinking preferences are not erased when a provider/model -is removed or becomes unavailable. New work using an unavailable selection fails -rather than falling back to another model. Select another configured model to -repair the slot. Custom-provider removal does not rewrite historical records; -like existing API-key models, historical cost estimates use the current catalog -and become unknown when the relevant model definition is unavailable. +Edits use an opaque nonsecret ETag/If-Match revision. A stale edit returns **409** with +reload/retry guidance. A connect also checks configuration and effective credentials +after discovery, and merges only the custom section into the latest settings record, +preserving intervening unrelated edits. Failed mutations never claim success. + +Desktop requests use the existing desktop capability. Browser mutations must be +same-origin JSON requests. Limits are **15 seconds** for discovery, **8 MiB** for its +response, **10,000** listed models, and **64 KiB** for configuration-write bodies. +These are protections for the new endpoint, not a multiuser authentication system. + +## Pricing and previous development configuration + +Custom model pricing is **unknown/unpriced**, not $0—even for an endpoint that may +be locally hosted. Pi-required internal zero-rate placeholders are not presented as +free inference. There is no rate editor in this release. + +The earlier, unshipped app-data `models.json` feature is retired and is **not read**. +No user file is deleted or renamed, and there is no importer, coexistence mode, +profile reset, history rewrite, or silent model remapping. Existing development +profiles need manual endpoint configuration and model reselection. Unbound early +GUI development configurations also require explicit reconnect/new-key entry or +the existing reuse-consent checkbox, even at the same URL; they are never silently +trusted or migrated. This release +supports one OpenAI-compatible custom endpoint, not multiple arbitrary protocols. diff --git a/scripts/smoke-provider-runtime.mjs b/scripts/smoke-provider-runtime.mjs index 7cafc1d..eb63e4f 100644 --- a/scripts/smoke-provider-runtime.mjs +++ b/scripts/smoke-provider-runtime.mjs @@ -80,6 +80,14 @@ async function probe(standalone) { // factories, never the checkout's module or a direct/fake Google adapter. const entry = pathToFileURL(path.join(root, "node_modules/@earendil-works/pi-ai/dist/index.js")); const { createModels } = await import(entry.href); + // Keep every native chat implementation evaluable in the standalone + // payload even though the custom-provider UI intentionally exposes one API. + for (const api of ["anthropic-messages", "openai-completions", "openai-responses", "openai-codex-responses", "azure-openai-responses", "google-generative-ai", "google-vertex", "bedrock-converse-stream", "mistral-conversations", "pi-messages"]) { + const implementation = await import(new URL(`api/${api}.js`, entry).href); + assert.equal(typeof implementation.streamSimple, "function", `${api}: native implementation missing`); + } + noNetwork(); + console.log("[smoke-provider-runtime] all 10 native chat implementations evaluated offline"); const models = createModels(); for (const [name, factory, api, label] of [ ["google", "googleProvider", "google-generative-ai", "Google"], diff --git a/src/agent/custom-providers.ts b/src/agent/custom-providers.ts deleted file mode 100644 index 5c82773..0000000 --- a/src/agent/custom-providers.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** Native Pi providers assembled from validated configuration. No IO or registry - * here: the server supplies key lookup, and Pi owns model dispatch/streaming. */ -import { createProvider, type Provider, type ProviderStreams } from "@earendil-works/pi-ai"; -import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy"; -import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy"; -import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy"; -import { openAICodexResponsesApi } from "@earendil-works/pi-ai/api/openai-codex-responses.lazy"; -import { azureOpenAIResponsesApi } from "@earendil-works/pi-ai/api/azure-openai-responses.lazy"; -import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy"; -import { googleVertexApi } from "@earendil-works/pi-ai/api/google-vertex.lazy"; -import { lazyApi } from "@earendil-works/pi-ai/api/lazy"; -import { mistralConversationsApi } from "@earendil-works/pi-ai/api/mistral-conversations.lazy"; -import { piMessagesApi } from "@earendil-works/pi-ai/api/pi-messages.lazy"; -import { resolveCustomValue, type CustomProviderConfig } from "../core/custom-providers.ts"; - -const apis: Record = { - "anthropic-messages": anthropicMessagesApi(), - "openai-completions": openAICompletionsApi(), - "openai-responses": openAIResponsesApi(), - "openai-codex-responses": openAICodexResponsesApi(), - "azure-openai-responses": azureOpenAIResponsesApi(), - "google-generative-ai": googleGenerativeAIApi(), - "google-vertex": googleVertexApi(), - // Pi's Bedrock wrapper intentionally hides its Node-only import from - // bundlers. Give this server's standalone tracer a literal public import; - // Pi still owns both lazy forwarding and the unchanged native transport. - "bedrock-converse-stream": lazyApi(() => import("@earendil-works/pi-ai/api/bedrock-converse-stream")), - "mistral-conversations": mistralConversationsApi(), - "pi-messages": piMessagesApi(), -}; -export const customProviderApis: ReadonlySet = new Set(Object.keys(apis)); - -function resolveHeaders(headers: Record | undefined, lookup: (name: string) => string | undefined): Record | undefined { - const result: Record = {}; - for (const [name, value] of Object.entries(headers ?? {})) { - const resolved = resolveCustomValue(value, lookup); - if (resolved === undefined) return undefined; - result[name] = resolved; - } - return result; -} -export function customProviderAuth(config: CustomProviderConfig, lookup: (name: string) => string | undefined) { - const key = config.definition.apiKey === undefined ? undefined : resolveCustomValue(config.definition.apiKey, lookup); - const headers = resolveHeaders(config.definition.headers, lookup); - if (!key || !headers) return undefined; - // A model header is part of that model's request configuration. Unresolved - // references must not silently disappear from a request. - for (const model of config.models) if (!resolveHeaders(model.headers, lookup)) return undefined; - if (config.definition.authHeader) { - for (const name of Object.keys(headers)) if (name.toLowerCase() === "authorization") delete headers[name]; - headers.Authorization = `Bearer ${key}`; - } - return { apiKey: key, headers }; -} - -export function createCustomProvider(config: CustomProviderConfig, lookup: (name: string) => string | undefined): Provider { - const auth = customProviderAuth(config, lookup); - return createProvider({ - id: config.id, - name: config.definition.name ?? config.id, - baseUrl: config.definition.baseUrl, - models: config.models.map((model) => ({ ...model, headers: resolveHeaders(model.headers, lookup) })), - auth: { apiKey: { - name: "API key", - check: async () => auth ? { type: "api_key", source: "custom provider configuration" } : undefined, - resolve: async () => auth ? { auth, source: "custom provider configuration" } : undefined, - } }, - // Direct native implementations: no copied stream or request conversion. - api: apis, - }); -} diff --git a/src/agent/models.ts b/src/agent/models.ts index 8edd8d5..37a98d2 100644 --- a/src/agent/models.ts +++ b/src/agent/models.ts @@ -9,7 +9,7 @@ import { type StreamFunction, } from "@earendil-works/pi-ai"; import { builtinProviders } from "@earendil-works/pi-ai/providers/all"; -import { lazyStream } from "@earendil-works/pi-ai/api/lazy"; +import { lazyApi, lazyStream } from "@earendil-works/pi-ai/api/lazy"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { type ModelRole } from "../../config.ts"; import { DEFAULT_THINKING_ORDER } from "../core/settings.ts"; @@ -82,7 +82,14 @@ export function configureProviderSource(source: ProviderSource): void { export function piModels() { if (!collection) { collection = createModels({ identity: APP_IDENTITY }); - for (const provider of builtinProviders()) collection.setProvider(provider); + for (const provider of builtinProviders()) { + // Keep the native Bedrock implementation reachable by standalone tracing. + // Pi's default Node-only loader hides this import from bundlers. Only + // replace its lazy forwarding; preserve the native factory's auth/catalog. + collection.setProvider(provider.id === "amazon-bedrock" + ? { ...provider, ...lazyApi(() => import("@earendil-works/pi-ai/api/bedrock-converse-stream")) } + : provider); + } collection.setProvider(createClaudeCodeProvider(streamClaudeCode)); } providerSource?.sync(collection); @@ -124,7 +131,12 @@ function defaultThinkingLevel(model: Model): ThinkingLevel { export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (() => boolean) = true, lifecycle?: ChatGPTDispatchLifecycle): PiEngine { const models: Models = piModels(); const nativeId = nativeSubscriptionId(spec.provider); - const model = spec.provider === "chatgpt" ? chatGPTModel(spec.id) : nativeId ? nativeSubscriptionModel(spec.provider, spec.id) : models.getModel(spec.provider, spec.id); + const registeredModel = spec.provider === "chatgpt" ? chatGPTModel(spec.id) : nativeId ? nativeSubscriptionModel(spec.provider, spec.id) : models.getModel(spec.provider, spec.id); + const custom = providerSource?.isCustom(spec.provider) ?? false; + // Custom endpoint owners authorize image attempts independently of sparse + // discovery metadata. Never promote the registered/catalog capabilities. + const permitImages = (value: Model): Model => ({ ...value, input: ["text", "image"] }); + const model = custom && registeredModel ? permitImages(registeredModel) : registeredModel; if (!model) { throw new Error(`${spec.provider} catalog has no model ${spec.id}`); } @@ -137,7 +149,6 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | ( : defaultThinkingLevel(model); // A lesson's agent loop can stay alive through many tool calls. Remember // only that this is a custom route, never its configuration for that run. - const custom = providerSource?.isCustom(spec.provider) ?? false; return { model, level, @@ -156,7 +167,7 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | ( const currentLevel = thinkingPreference ? clampThinkingLevel(currentModel, thinkingPreference) : defaultThinkingLevel(currentModel); - return current.streamSimple(currentModel, context, { ...options, reasoning: currentLevel === "off" ? undefined : currentLevel }); + return current.streamSimple(permitImages(currentModel), context, { ...options, reasoning: currentLevel === "off" ? undefined : currentLevel }); } const requestOptions = { ...options, reasoning: level === "off" ? undefined : level }; const purpose = typeof background === "function" ? background : () => background; diff --git a/src/core/contracts/models.ts b/src/core/contracts/models.ts index e0225a2..22000ec 100644 --- a/src/core/contracts/models.ts +++ b/src/core/contracts/models.ts @@ -75,8 +75,6 @@ export interface ModelCatalogResponse { * are simply absent (the Keys page is the discovery surface, same * discipline as the search engines). */ providers: CatalogProvider[]; - /** Sanitized configuration diagnostic; built-in catalogs remain available. */ - customProvidersError?: string; } /** One row of the providers surface. */ diff --git a/src/core/custom-provider-auth.ts b/src/core/custom-provider-auth.ts new file mode 100644 index 0000000..8f161ee --- /dev/null +++ b/src/core/custom-provider-auth.ts @@ -0,0 +1,26 @@ +/** Private custom credential authorization; no credential-derived public value. */ +import { normalizeBaseUrl, type CustomProviderSettings } from "./settings.ts"; + +export const CUSTOM_AUTH_BINDING = "__learnharness_custom_provider_binding"; +export interface CustomAuthBinding { + version: 1; + id: string; + baseUrl?: string; + authMode: "apiKey" | "keyless" | "disabled"; + /** A shadowed file key keeps its own destination when an env key moves. */ + fileKeyBaseUrl?: string; +} +export function parseCustomAuthBinding(value: unknown): CustomAuthBinding | undefined { + try { + const b = typeof value === "string" ? JSON.parse(value) : undefined; + if (!b || b.version !== 1 || typeof b.id !== "string" || !/^[a-f0-9]{32}$/.test(b.id)) return; + if (!["apiKey", "keyless", "disabled"].includes(b.authMode)) return; + if (b.authMode !== "disabled" && b.baseUrl === undefined) return; + if (b.baseUrl !== undefined && (typeof b.baseUrl !== "string" || !b.baseUrl || normalizeBaseUrl(b.baseUrl) !== b.baseUrl)) return; + if (b.fileKeyBaseUrl !== undefined && (typeof b.fileKeyBaseUrl !== "string" || !b.fileKeyBaseUrl || normalizeBaseUrl(b.fileKeyBaseUrl) !== b.fileKeyBaseUrl)) return; + return { version: 1, id: b.id, authMode: b.authMode, ...(b.baseUrl !== undefined ? { baseUrl: b.baseUrl } : {}), ...(b.fileKeyBaseUrl !== undefined ? { fileKeyBaseUrl: b.fileKeyBaseUrl } : {}) }; + } catch { return; } +} +export function customBindingMatches(cp: CustomProviderSettings | undefined, binding: CustomAuthBinding | undefined): boolean { + return Boolean(cp?.baseUrl && binding && binding.authMode !== "disabled" && cp.credentialBindingId === binding.id && cp.baseUrl === binding.baseUrl && cp.authMode === binding.authMode); +} diff --git a/src/core/custom-providers.ts b/src/core/custom-providers.ts deleted file mode 100644 index 9aaed27..0000000 --- a/src/core/custom-providers.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** Declarative custom-provider configuration. The envelope/normalization follows - * Pi coding-agent 1.4.0's model-config/provider-composer (MIT, Mario Zechner). - * Pi AI owns the shared model/compatibility schemas and the runtime models. - * Deliberate differences: new provider IDs only, explicit limits/prices, - * and no OAuth, extensions, overrides, or executable credential commands. - * - * Adapted portions: Copyright (c) Mario Zechner. Permission is hereby granted, - * free of charge, to any person obtaining a copy of this software and associated - * documentation files (the "Software"), to deal in the Software without - * restriction, including without limitation the rights to use, copy, modify, - * merge, publish, distribute, sublicense, and/or sell copies of the Software, - * and to permit persons to whom the Software is furnished to do so, subject to - * the following conditions: The above copyright notice and this permission - * notice shall be included in all copies or substantial portions of the Software. - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. */ -import { Type, type Static } from "typebox"; -import { Compile } from "typebox/compile"; -import type { Api, Model } from "@earendil-works/pi-ai"; -import { ProviderCompatSchema } from "@earendil-works/pi-ai/providers/compat-schema"; -import { ModelCostSchema, ModelInputLimitsSchema, ModelInputModalitySchema, ModelPromptCacheSchema, ThinkingLevelMapSchema } from "@earendil-works/pi-ai/providers/model-schema"; - -const Headers = Type.Record(Type.String(), Type.String()); -const Sampling = Type.Record(Type.String(), Type.Unknown()); -const ModelSchema = Type.Object({ - id: Type.String({ minLength: 1 }), - name: Type.Optional(Type.String({ minLength: 1 })), - api: Type.Optional(Type.String({ minLength: 1 })), - baseUrl: Type.Optional(Type.String({ minLength: 1 })), - reasoning: Type.Optional(Type.Boolean()), - thinkingLevelMap: Type.Optional(ThinkingLevelMapSchema), - input: Type.Optional(Type.Array(ModelInputModalitySchema)), - inputLimits: Type.Optional(ModelInputLimitsSchema), - cost: ModelCostSchema, - promptCache: Type.Optional(ModelPromptCacheSchema), - contextWindow: Type.Number({ exclusiveMinimum: 0 }), - maxTokens: Type.Number({ exclusiveMinimum: 0 }), - samplingParams: Type.Optional(Sampling), - samplingParamsByThinkingLevel: Type.Optional(Type.Object(Object.fromEntries( - ["off", "minimal", "low", "medium", "high", "xhigh", "max"].map((level) => [level, Type.Optional(Sampling)]), - ))), - headers: Type.Optional(Headers), - compat: Type.Optional(ProviderCompatSchema), -}, { additionalProperties: false }); -const ProviderSchema = Type.Object({ - name: Type.Optional(Type.String({ minLength: 1 })), - baseUrl: Type.Optional(Type.String({ minLength: 1 })), - api: Type.Optional(Type.String({ minLength: 1 })), - apiKey: Type.Optional(Type.String({ minLength: 1 })), - headers: Type.Optional(Headers), - compat: Type.Optional(ProviderCompatSchema), - authHeader: Type.Optional(Type.Boolean()), - models: Type.Array(ModelSchema), -}, { additionalProperties: false }); -const ConfigSchema = Type.Object({ - $schema: Type.Optional(Type.String()), - providers: Type.Record(Type.String(), ProviderSchema), -}, { additionalProperties: false }); -const validator = Compile(ConfigSchema); -export type CustomProviderDefinition = Static; -export interface CustomProviderConfig { - id: string; - definition: CustomProviderDefinition; - models: Model[]; -} - -/** Error messages describe fields only: never include file contents or values. */ -export class CustomProviderConfigError extends Error {} -function invalid(message: string): never { throw new CustomProviderConfigError(message); } - -/** Pi-style values, but never execute commands. Escapes are processed once; - * a literal/variable's resolved value beginning with ! is still just data. */ -export function resolveCustomValue(value: string, lookup: (name: string) => string | undefined): string | undefined { - if (value.startsWith("!")) invalid("Credential commands are not supported in models.json."); - let missing = false; - const resolved = value.replace(/\$\$|\$!|\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, braced: string | undefined, plain: string | undefined) => { - if (match === "$$") return "$"; - if (match === "$!") return "!"; - const found = lookup(braced ?? plain!); - if (found === undefined) missing = true; - return found ?? ""; - }); - return missing ? undefined : resolved; -} - -function endpoint(value: string | undefined): string { - if (!value) invalid("Each custom model needs an api and baseUrl, at provider or model level."); - try { - const url = new URL(value); - if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.hash) throw new Error(); - } catch { invalid("Custom baseUrl must be an HTTP(S) URL without embedded credentials or a fragment."); } - return value; -} -function compat(base: Model["compat"], override: Model["compat"]): Model["compat"] { - if (!base && !override) return undefined; - const merged = { ...base, ...override } as Record; - for (const name of ["openRouterRouting", "vercelGatewayRouting", "chatTemplateKwargs", "chatTemplateArgs"]) { - const a = (base as Record | undefined)?.[name]; - const b = (override as Record | undefined)?.[name]; - if (a || b) merged[name] = { ...(a as object | undefined), ...(b as object | undefined) }; - } - return merged as Model["compat"]; -} - -export function parseCustomProviders(text: string, reserved: ReadonlySet, supportedApis: ReadonlySet): CustomProviderConfig[] { - let raw: unknown; - // Match Pi's line comments/trailing commas without touching JSON strings. - const json = text.replace(/^\uFEFF/, "") - .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (match) => match[0] === '"' ? match : "") - .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (match, tail: string | undefined) => tail ?? (match[0] === '"' ? match : "")); - try { raw = JSON.parse(json); } - catch { return invalid("models.json is not valid JSON."); } - if (!validator.Check(raw)) invalid("Invalid models.json structure: check provider/model fields and explicit contextWindow, maxTokens and cost."); - return Object.entries(raw.providers).map(([id, definition]) => { - if (!id.trim() || id.includes(":")) invalid("Custom provider IDs must be nonempty and must not contain a colon."); - if (reserved.has(id)) invalid("Custom provider IDs must not replace built-in or subscription providers."); - for (const value of [definition.apiKey, ...Object.values(definition.headers ?? {}), ...definition.models.flatMap((m) => Object.values(m.headers ?? {}))]) { - if (value !== undefined) resolveCustomValue(value, () => "validation"); - } - const ids = new Set(); - const models = definition.models.map((model): Model => { - if (ids.has(model.id)) invalid("Duplicate model IDs are not supported within a custom provider."); - ids.add(model.id); - const api = model.api ?? definition.api; - if (!api || !supportedApis.has(api)) invalid("A custom model specifies a missing or unsupported Pi API."); - const rates = [model.cost, ...(model.cost.tiers ?? [])]; - if (![model.contextWindow, model.maxTokens].every(Number.isFinite) || rates.some((rate) => Object.values(rate).some((n) => typeof n === "number" && (!Number.isFinite(n) || n < 0)))) invalid("Custom model limits and costs must be finite; limits must be positive and costs nonnegative."); - return { - ...model, - provider: id, - api, - baseUrl: endpoint(model.baseUrl ?? definition.baseUrl), - name: model.name ?? model.id, - reasoning: model.reasoning ?? false, - input: model.input ?? ["text"], - compat: compat(definition.compat, model.compat), - }; - }); - return { id, definition, models }; - }); -} diff --git a/src/core/settings.ts b/src/core/settings.ts index 8dc6a37..3d6ded3 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -215,15 +215,12 @@ export interface ModelsSettings { // secret in the auth store, never here. Registered into pi-ai as a normal // provider (server/custom-provider.ts). -/** Defaults for a custom model — self-hosted endpoints are free. */ +/** Budget fallbacks only; custom endpoint pricing is unknown. */ export const CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 8192; export const CUSTOM_MODEL_DEFAULT_MAX_TOKENS = 4096; -/** Floor for a user-pinned token count (owner, 2026-10-27): the read side - * clamps up, the write side rejects below — the compaction-floor discipline. - * Pins exist because endpoints that publish no limits (LM Studio) otherwise - * sit on the 8192 default with no way to say better. */ -export const CUSTOM_MODEL_PIN_FLOOR = 2_048; +/** Custom budgets accept every positive integer, including small output caps. */ +export const CUSTOM_MODEL_PIN_FLOOR = 1; /** One discovered custom model. `image`/`reasoning`/`context`/`maxTokens` are * detected from the endpoint's /models metadata; anything it omits falls back @@ -244,8 +241,8 @@ export interface CustomModel { /** A user pin for one custom model: it beats what the endpoint publishes * (owner ruling — explicit user intent wins; a local server's number is * often wrong or absent). Absent field = no pin. `reasoning` is tri-state: - * true forces thinking on, false forces it off (a server that advertises - * reasoning but rejects reasoning_effort), absent = what discovery found. */ + * true declares reasoning support, false removes that declaration, absent + * uses discovery. This is NOT a backend execution/disable switch. */ export interface CustomModelOverride { context?: number; maxTokens?: number; @@ -254,6 +251,10 @@ export interface CustomModelOverride { /** The single custom endpoint's stored config (delta section). */ export interface CustomProviderSettings { + /** Server-owned random authorization nonce; never a credential hash. */ + credentialBindingId?: string; + /** Explicit authentication intent; never inferred from a saved key. */ + authMode?: "keyless" | "apiKey"; /** Normalized base URL (see normalizeBaseUrl). Empty/absent = not configured. */ baseUrl?: string; /** Models this endpoint offers: advertised ids merged with the typed @@ -287,6 +288,7 @@ export function normalizeBaseUrl(raw: string): string { return ""; } if (url.protocol !== "http:" && url.protocol !== "https:") return ""; + if (url.username || url.password || url.search || url.hash || value.includes("?") || value.includes("#")) return ""; return url.toString().replace(/\/+$/, ""); } @@ -625,7 +627,7 @@ export function sanitizeCustomModelOverrides( const id = key.trim(); if (!id || (live && !live.has(id))) continue; const pin = sanitizeCustomModelOverride(value); - if (pin) out[id] = pin; + if (pin) Object.defineProperty(out, id, { value: pin, enumerable: true, configurable: true, writable: true }); } return out; } @@ -635,6 +637,8 @@ export function sanitizeCustomModelOverrides( * true; token counts only as positive ints). Clamp-never-reject. */ function sanitizeCustomProvider(raw: Record): CustomProviderSettings { const out: CustomProviderSettings = {}; + if (typeof raw.credentialBindingId === "string" && /^[a-f0-9]{32}$/.test(raw.credentialBindingId)) out.credentialBindingId = raw.credentialBindingId; + if (raw.authMode === "keyless" || raw.authMode === "apiKey") out.authMode = raw.authMode; if (typeof raw.baseUrl === "string") { const base = normalizeBaseUrl(raw.baseUrl); if (base) out.baseUrl = base; diff --git a/src/server/custom-provider-auth.ts b/src/server/custom-provider-auth.ts new file mode 100644 index 0000000..aa04025 --- /dev/null +++ b/src/server/custom-provider-auth.ts @@ -0,0 +1,43 @@ +/** Custom-only credential composition. File credentials and their authorization + * are read from the same atomic record, never a stale fill-gapped env copy. */ +import { CUSTOM_PROVIDER } from "../core/auth-keys.ts"; +import { authEnvRecord } from "../core/auth-env.ts"; +import { customBindingMatches, type CustomAuthBinding } from "../core/custom-provider-auth.ts"; +import type { CustomProviderSettings } from "../core/settings.ts"; +import { readCustomAuth, writeCustomAuth } from "../store/auth.ts"; + +export interface CustomAuthState { + readable: boolean; + binding?: CustomAuthBinding; + key?: string; + fileKey?: string; + source: "env" | "file" | "none"; +} +/** requiredConfig prevents even credential resolution until startup/runtime + * authorization matches. Explicit UI inspection/probe authorization omits it. */ +export function customAuthState(inspectKey = false, requiredConfig?: CustomProviderSettings): CustomAuthState { + try { + const snapshot = readCustomAuth(); + const result: CustomAuthState = { readable: true, binding: snapshot.binding, source: "none" }; + if (!inspectKey || (requiredConfig && !customBindingMatches(requiredConfig, snapshot.binding))) return result; + result.fileKey = snapshot.fileKey(); + const record = authEnvRecord(); + const envKey = process.env.LH_IGNORE_ENV_KEYS !== "1" && record.realEnv.has(CUSTOM_PROVIDER.envVar) ? process.env[CUSTOM_PROVIDER.envVar] : undefined; + result.source = envKey !== undefined ? "env" : result.fileKey !== undefined ? "file" : "none"; + result.key = (envKey ?? result.fileKey)?.trim() || undefined; + return result; + } catch { return { readable: false, source: "none" }; } +} +export function customKeyAuthorized(cp: CustomProviderSettings | undefined, state: CustomAuthState): boolean { + return cp?.authMode === "apiKey" && state.readable && customBindingMatches(cp, state.binding) && Boolean(state.key) && (state.source === "env" || state.binding?.fileKeyBaseUrl === cp?.baseUrl); +} +/** Reflect only after the atomic pair is committed. Runtime file resolution + * does not rely on this compatibility mirror, including after a failed write. */ +export function persistCustomAuth(binding: CustomAuthBinding | undefined, fileKey: string | undefined): void { + writeCustomAuth(binding, fileKey); + const record = authEnvRecord(); + if (!record.realEnv.has(CUSTOM_PROVIDER.envVar)) { + if (fileKey === undefined) { delete process.env[CUSTOM_PROVIDER.envVar]; record.fillGap.delete(CUSTOM_PROVIDER.envVar); } + else { process.env[CUSTOM_PROVIDER.envVar] = fileKey; record.fillGap.add(CUSTOM_PROVIDER.envVar); } + } +} diff --git a/src/server/custom-provider-body.ts b/src/server/custom-provider-body.ts new file mode 100644 index 0000000..4abac5e --- /dev/null +++ b/src/server/custom-provider-body.ts @@ -0,0 +1,31 @@ +/** Byte-bounded JSON for the custom endpoint's request/discovery surfaces. */ +export class CustomBodyLimitError extends Error {} + +export async function readBoundedJson(message: Request | Response, limit: number, allowEmptyObject = false): Promise { + if (Number(message.headers.get("content-length")) > limit) { + await message.body?.cancel(); + throw new CustomBodyLimitError("JSON body exceeds the allowed byte limit"); + } + const reader = message.body?.getReader(); + if (!reader) { + if (allowEmptyObject) return {}; + throw new SyntaxError("Missing JSON body"); + } + const decoder = new TextDecoder(); + let text = ""; + let bytes = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > limit) throw new CustomBodyLimitError("JSON body exceeds the allowed byte limit"); + text += decoder.decode(value, { stream: true }); + } + if (bytes === 0 && allowEmptyObject) return {}; + return JSON.parse(text + decoder.decode()); + } finally { + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } +} diff --git a/src/server/custom-provider-http.ts b/src/server/custom-provider-http.ts new file mode 100644 index 0000000..ad240cb --- /dev/null +++ b/src/server/custom-provider-http.ts @@ -0,0 +1,149 @@ +/** PR #62's connect/manual-model/offline-pin workflow, with host request, + * credential-binding and optimistic-write boundaries. No secret reaches GET. */ +import { createHmac, randomBytes } from "node:crypto"; +import { normalizeBaseUrl, parseModelIds, sanitizeCustomModelOverrides, type CustomProviderSettings } from "../core/settings.ts"; +import { readSettings, writeSettings } from "../store/settings.ts"; +import { readCustomAuth } from "../store/auth.ts"; +import { customAuthState, customKeyAuthorized, persistCustomAuth } from "./custom-provider-auth.ts"; +import type { CustomAuthBinding } from "../core/custom-provider-auth.ts"; +import { desktopAPIAuthorized } from "./desktop-access.ts"; +import { subscriptionRequestOrigin } from "./web-subscription-http.ts"; +import { discoverModels, mergeManualModels, syncCustomProvider } from "./custom-provider.ts"; +import { parseCustomModelOverridesBody } from "./settings-body.ts"; +import { CustomBodyLimitError, readBoundedJson } from "./custom-provider-body.ts"; + +const revisionKey = Symbol.for("learnharness.custom-provider-revision"); +const globals = globalThis as typeof globalThis & { [revisionKey]?: Buffer }; +const secret = globals[revisionKey] ??= randomBytes(32); +const failure = (error: string, status = 400) => Response.json({ error }, { status }); +const stale = () => failure("Custom configuration or credentials changed. Reload configuration and retry.", 409); + +function snapshot(inspectKey = false) { + const record = readSettings() ?? { version: 1 }; + const cp = record.customProvider; + // Keyless never looks up a key for authentication or revision generation. + // An explicit API-key inspection is used only after the user selects that mode. + const auth = customAuthState(inspectKey); + const key = auth.key; + // Public revisions contain configuration only, never credential material. + // Effective credentials are compared privately across the awaited probe. + const etag = `"${createHmac("sha256", secret).update(JSON.stringify([cp ?? null, auth.binding ?? null, auth.readable])).digest("base64url")}"`; + return { record, cp, auth, key, etag }; +} + +function response(body: object) { + return Response.json(body, { headers: { etag: snapshot().etag, "cache-control": "no-store" } }); +} + +export function customProviderGet(request: Request): Response { + const inspect = new URL(request.url).searchParams.get("authMode") === "apiKey"; + const current = snapshot(); + const s = snapshot(inspect || current.cp?.authMode === "apiKey"); + return response({ + baseUrl: s.cp?.baseUrl ?? "", authMode: s.cp?.authMode ?? "keyless", + models: s.cp?.models ?? [], overrides: s.cp?.overrides ?? {}, manualModels: s.cp?.manualModels ?? [], + keyPresent: Boolean(s.key), keySource: s.auth.source, + keyReuseRequired: Boolean(s.key) && !customKeyAuthorized(s.cp, s.auth), + }); +} + +function authorized(request: Request): boolean { + const desktop = process.env.LH_DESKTOP_SERVER === "1" || Boolean(process.env.LH_DESKTOP_API_SECRET); + if (desktop) return desktopAPIAuthorized(request.headers); + const origin = request.headers.get("origin"); + if (!origin || origin === "null") return false; + try { + // Next's standalone Request URL can use its internal hostname. Match + // the actual addressed Host through the app's existing origin seam; + // never trust forwarded-origin headers. + return origin === subscriptionRequestOrigin(request) + && (!request.headers.has("sec-fetch-site") || request.headers.get("sec-fetch-site") === "same-origin"); + } catch { return false; } +} + +/** Auth pair FIRST, matching config nonce SECOND. Any interrupted/failed + * cross-file publication is durably unusable, including with a real env key. + * Best-effort rollback restores the entire old pair, never just its secret. */ +function commit(cp: CustomProviderSettings | undefined, binding?: CustomAuthBinding, fileKey?: string): void { + const latest = readSettings() ?? { version: 1 }; + const oldAuth = binding ? readCustomAuth() : undefined; + let published = false; + try { + if (binding) { persistCustomAuth(binding, fileKey); published = true; } + const { customProvider: _old, ...rest } = latest; + writeSettings(cp ? { ...rest, customProvider: cp } : rest); + } catch { + if (published && oldAuth) { + try { persistCustomAuth(oldAuth.binding, oldAuth.fileKey()); } catch { /* durable nonce mismatch fails closed after restart */ } + } + throw new Error("Unable to save custom configuration. Reload configuration and retry."); + } + syncCustomProvider(); +} + +export async function customProviderMutation(request: Request): Promise { + if (!authorized(request)) return failure("Custom configuration requires a trusted same-origin request.", 403); + if (request.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() !== "application/json") return failure("JSON content-type is required.", 415); + try { + // Next can represent an empty DELETE as a zero-byte stream rather + // than null. Bound it either way; only DELETE permits an empty body. + const body = await readBoundedJson(request, 64 * 1024, request.method === "DELETE"); + if (!body || typeof body !== "object" || Array.isArray(body)) return failure("Invalid JSON body"); + const raw = body as Record; + const before = snapshot(raw.authMode === "apiKey"); + if (!before.auth.readable) return failure("Custom authorization storage is unavailable. No changes were saved.", 500); + if (request.headers.get("if-match") !== before.etag) return stale(); + if (request.method === "DELETE") { + commit(undefined, { version: 1, id: randomBytes(16).toString("hex"), authMode: "disabled" }); + return response({ ok: true }); + } + if (request.method === "PATCH") { + const parsed = parseCustomModelOverridesBody(raw); + if (!parsed.ok) return failure(parsed.error); + if (!before.cp?.baseUrl) return failure("No custom endpoint configured", 409); + const overrides = sanitizeCustomModelOverrides(parsed.settings, before.cp.models?.map((m) => m.id)); + commit({ ...before.cp, overrides }); + return response({ ok: true, overrides }); + } + if (request.method !== "POST") return failure("Unsupported method", 405); + const base = normalizeBaseUrl(typeof raw.baseUrl === "string" ? raw.baseUrl : ""); + if (!base) return failure("Base URL must be http(s), with no credentials, query or fragment."); + if (raw.authMode !== "keyless" && raw.authMode !== "apiKey") return failure("Choose Keyless or API key authentication."); + const authMode = raw.authMode; + if (raw.key !== undefined && typeof raw.key !== "string") return failure("API key must be a string."); + const supplied = typeof raw.key === "string" ? raw.key.trim() : undefined; + if (authMode === "apiKey" && supplied === "") return failure("Choose Keyless to clear authentication."); + const envOwned = before.auth.source === "env"; + if (envOwned && supplied) return failure("Environment owns the effective key. Change the environment or explicitly reuse that key."); + // A shadowed file key is not the key that will run. Require consent for + // the actual environment key when authorizing a different endpoint. + const hasNewEffectiveKey = Boolean(supplied) && !envOwned; + const changedTarget = before.cp?.baseUrl !== base; + if (authMode === "apiKey" && (changedTarget || !customKeyAuthorized(before.cp, before.auth)) && !hasNewEffectiveKey && raw.reuseKey !== true) { + return failure("Enter a new key or explicitly allow reuse of the effective existing key for this URL."); + } + const effectiveKey = authMode === "apiKey" ? (envOwned ? before.key : supplied || before.key) : undefined; + if (authMode === "apiKey" && !effectiveKey) return failure("API key mode requires an effective key."); + const manualModels = parseModelIds(raw.manualModels); + if (manualModels.length > 10_000) return failure("Model list exceeds 10000 entries"); + const found = await discoverModels(base, effectiveKey); + if (!found.ok && (found.fatal || found.status === 401 || found.status === 403)) return failure(found.error, 502); + if (!found.ok && !manualModels.length) return failure(`${found.error}. If this endpoint has no /models list, add model IDs manually.`, 502); + // Discovery yields; both the custom record and the credential must still + // match. Unrelated settings edits are preserved by commit's fresh merge. + const now = snapshot(authMode === "apiKey"); + if (before.etag !== now.etag || JSON.stringify(before.auth) !== JSON.stringify(now.auth)) return stale(); + const discovered = found.ok ? found.models : !changedTarget ? before.cp?.models ?? [] : []; + const models = mergeManualModels(discovered, manualModels); + if (models.length > 10_000) return failure("Model list exceeds 10000 entries"); + const overrides = sanitizeCustomModelOverrides(!changedTarget ? before.cp?.overrides : undefined, models.map((m) => m.id)); + const id = randomBytes(16).toString("hex"); + const fileKey = authMode === "keyless" ? undefined : supplied && !envOwned ? supplied : now.auth.fileKey; + const fileKeyBaseUrl = authMode === "apiKey" ? envOwned ? now.auth.binding?.fileKeyBaseUrl : base : undefined; + const binding: CustomAuthBinding = { version: 1, id, baseUrl: base, authMode, ...(fileKey && fileKeyBaseUrl ? { fileKeyBaseUrl } : {}) }; + commit({ baseUrl: base, authMode, credentialBindingId: id, models, overrides, ...(manualModels.length ? { manualModels } : {}) }, binding, fileKey); + return response({ ok: true, models }); + } catch (error) { + return failure(error instanceof CustomBodyLimitError ? "Configuration body exceeds 64 KiB." : error instanceof SyntaxError ? "Invalid JSON body." : "Unable to save custom configuration. Reload configuration and retry.", error instanceof CustomBodyLimitError ? 413 : error instanceof SyntaxError ? 400 : 500); + } +} diff --git a/src/server/custom-provider.ts b/src/server/custom-provider.ts index 2d947e6..b717022 100644 --- a/src/server/custom-provider.ts +++ b/src/server/custom-provider.ts @@ -5,10 +5,14 @@ * local servers work via a placeholder key resolved at request time. */ -import { createProvider, type ApiKeyAuth, type Api, type Model, type Provider } from "@earendil-works/pi-ai"; +import { createProvider, type Api, type Model, type Provider } from "@earendil-works/pi-ai"; import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy"; -import { piModels } from "../agent/models.ts"; +import { configureProviderSource, piModels } from "../agent/models.ts"; +import { readSettings } from "../store/settings.ts"; +import { CustomBodyLimitError, readBoundedJson } from "./custom-provider-body.ts"; import { CUSTOM_PROVIDER } from "../core/auth-keys.ts"; +import { customBindingMatches } from "../core/custom-provider-auth.ts"; +import { customAuthState, customKeyAuthorized } from "./custom-provider-auth.ts"; import { CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW, CUSTOM_MODEL_DEFAULT_MAX_TOKENS, @@ -16,9 +20,8 @@ import { normalizeBaseUrl, type CustomModel, type CustomModelOverride, - type CustomProviderSettings, } from "../core/settings.ts"; -import { keyValue } from "./keys.ts"; + /** Placeholder bearer for keyless endpoints (the OpenAI client needs *a* key). */ const KEYLESS_PLACEHOLDER = "not-needed"; @@ -27,14 +30,32 @@ const KEYLESS_PLACEHOLDER = "not-needed"; const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -/** Auth: the reserved key via the keys seam, else the keyless placeholder. */ -const customEndpointAuth: ApiKeyAuth = { - name: `${CUSTOM_PROVIDER.label} API key`, - resolve: async () => { - const key = keyValue(CUSTOM_PROVIDER.envVar)?.trim(); - return { auth: { apiKey: key || KEYLESS_PLACEHOLDER }, source: key ? CUSTOM_PROVIDER.envVar : "keyless" }; - }, -}; +/** Install the sole custom source: donor settings + explicit authentication. + * Publication captures a consistent snapshot; an already-started operation + * cannot acquire a later endpoint's key. No file configuration source exists. */ +export function installCustomProviderSource(readAuth = customAuthState): void { + let fingerprint: string | undefined; + configureProviderSource({ + isCustom: (id) => id === CUSTOM_PROVIDER.id, + sync(models) { + const cp = readSettings()?.customProvider; + // Validate the durable nonce/URL/mode before resolving ANY credential. + // Keyless reads only the nonsecret marker, never a file/env key. + const auth = readAuth(cp?.authMode === "apiKey", cp); + const key = auth.key; + const ready = customProviderUsable(cp) && auth.readable && customBindingMatches(cp, auth.binding) && (cp.authMode === "keyless" || customKeyAuthorized(cp, auth)); + const next = JSON.stringify([cp, auth]); // Private in-memory fingerprint, never a public revision. + if (next === fingerprint) return; + if (ready) models.setProvider(buildOpenAiCompatibleProvider({ ...cp, baseUrl: cp.baseUrl, models: cp.models ?? [], apiKey: key })); + else models.deleteProvider(CUSTOM_PROVIDER.id); + fingerprint = next; + }, + }); +} + +export function customProviderAvailable(): boolean { + return piModels().getProvider(CUSTOM_PROVIDER.id) !== undefined; +} /** Map a discovered model to a pi-ai entry: image→input, reasoning→thinking * levels, context/maxTokens→limits. Resolution ladder (owner, 2026-10-27): @@ -72,13 +93,18 @@ export function buildOpenAiCompatibleProvider(input: { models: readonly CustomModel[]; /** User pins by model id (see CustomModelOverride) — absent = none. */ overrides?: Record; + /** Already resolved for this publication; absent means explicit keyless. */ + apiKey?: string; }): Provider { const overrides = input.overrides ?? {}; return createProvider({ id: CUSTOM_PROVIDER.id, name: CUSTOM_PROVIDER.label, baseUrl: input.baseUrl, - auth: { apiKey: customEndpointAuth }, + auth: { apiKey: { + name: `${CUSTOM_PROVIDER.label} API key`, + resolve: async () => ({ auth: { apiKey: input.apiKey ?? KEYLESS_PLACEHOLDER }, source: input.apiKey ? CUSTOM_PROVIDER.envVar : "keyless" }), + } }, models: input.models.map((m) => toPiModel(m, input.baseUrl, overrides)), api: openAICompletionsApi(), }); @@ -86,19 +112,8 @@ export function buildOpenAiCompatibleProvider(input: { /** Re-sync the provider into piModels() (idempotent): usable → rebuild; * not usable → remove. Never throws. */ -export function syncCustomProvider(cp: CustomProviderSettings | undefined): void { - const models = piModels(); - if (!customProviderUsable(cp)) { - models.deleteProvider(CUSTOM_PROVIDER.id); - return; - } - models.setProvider( - buildOpenAiCompatibleProvider({ - baseUrl: normalizeBaseUrl(cp.baseUrl), - models: cp.models ?? [], - overrides: cp.overrides ?? {}, - }), - ); +export function syncCustomProvider(): void { + piModels(); } /** Outcome of a model-discovery probe. `status` is the endpoint's HTTP status @@ -106,7 +121,7 @@ export function syncCustomProvider(cp: CustomProviderSettings | undefined): void * model list. */ export type DiscoverResult = | { ok: true; models: CustomModel[] } - | { ok: false; error: string; status?: number }; + | { ok: false; error: string; status?: number; fatal?: boolean }; /** Probe GET {baseUrl}/models for model ids. Scheme validated (http/https * only); keyless endpoints answer without a bearer. Non-2xx or unparseable @@ -114,22 +129,26 @@ export type DiscoverResult = export async function discoverModels(baseUrl: string, apiKey?: string): Promise { const base = normalizeBaseUrl(baseUrl); if (!base) return { ok: false, error: "Base URL must be a valid http(s) URL" }; - const key = apiKey?.trim() || keyValue(CUSTOM_PROVIDER.envVar)?.trim(); + const key = apiKey?.trim(); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 15_000); try { const res = await fetch(`${base}/models`, { headers: key ? { authorization: `Bearer ${key}` } : {}, signal: controller.signal, + redirect: "manual", }); - if (!res.ok) return { ok: false, error: `Endpoint returned ${res.status} ${res.statusText}`, status: res.status }; - const body = (await res.json().catch(() => null)) as unknown; + if (res.status >= 300 && res.status < 400) { + await res.body?.cancel(); + return { ok: false, error: "Discovery redirects are not allowed", fatal: true }; + } + if (!res.ok) { await res.body?.cancel(); return { ok: false, error: `Endpoint returned HTTP ${res.status}`, status: res.status }; } + const body = await readBoundedJson(res, 8 * 1024 * 1024); const list = extractModels(body); if (list === null) return { ok: false, error: "Endpoint response was not a model list" }; return { ok: true, models: list }; } catch (err) { - const msg = err instanceof Error ? (err.name === "AbortError" ? "Endpoint timed out" : err.message) : "Request failed"; - return { ok: false, error: msg }; + return { ok: false, error: err instanceof CustomBodyLimitError ? err.message : controller.signal.aborted ? "Endpoint timed out" : "Discovery request failed", fatal: err instanceof CustomBodyLimitError }; } finally { clearTimeout(timer); } @@ -210,6 +229,7 @@ const MAX_TOKEN_KEYS = ["max_output_tokens"] as const; function extractModels(body: unknown): CustomModel[] | null { const rows = modelRows(body); if (rows === null) return null; + if (rows.length > 10_000) throw new CustomBodyLimitError("Model list exceeds 10000 entries"); const byId = new Map(); for (const row of rows) { if (typeof row === "string") { diff --git a/src/server/custom-providers.ts b/src/server/custom-providers.ts deleted file mode 100644 index d78342e..0000000 --- a/src/server/custom-providers.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** Configuration composition only. Pi's Models collection is the one runtime - * registry. A failed reload removes custom routes, never alters built-ins. */ -import type { MutableModels } from "@earendil-works/pi-ai"; -import { builtinProviders } from "@earendil-works/pi-ai/providers/all"; -import { createCustomProvider, customProviderApis, customProviderAuth } from "../agent/custom-providers.ts"; -import { configureProviderSource } from "../agent/models.ts"; -import { parseCustomProviders, resolveCustomValue, CustomProviderConfigError, type CustomProviderConfig } from "../core/custom-providers.ts"; -import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts"; -import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts"; -import { readCustomProvidersFile } from "../store/custom-providers.ts"; - -const reserved = new Set([...builtinProviders().map((p) => p.id), "chatgpt", CLAUDE_CODE_PROVIDER, ...NATIVE_SUBSCRIPTIONS.map((p) => p.logicalId)]); - -/** Instance-local so every Next server module graph registers into its own Pi - * collection. No process-global registry or personal Pi config is consulted. */ -export class CustomProviderSource { - private text?: string; - private fingerprint?: string; - private configs: CustomProviderConfig[] = []; - private registered = new Set(); - private ready = new Map(); - private error?: string; - - constructor( - private readonly read: typeof readCustomProvidersFile, - private readonly key: (name: string) => string | undefined, - ) {} - - sync(models: MutableModels): void { - try { - const record = this.read(); - if (record.error) throw new CustomProviderConfigError(record.error); - if (record.text !== this.text || this.error) { - this.configs = parseCustomProviders(record.text!, reserved, customProviderApis); - this.text = record.text; - this.fingerprint = undefined; - } - // Resolve only referenced variables through the app's existing key seam. - // Changes in credentials/headers trigger a new immutable provider snapshot - // even when the configuration file itself has not changed. - const values = new Map(); - for (const config of this.configs) { - for (const template of [config.definition.apiKey, ...Object.values(config.definition.headers ?? {}), ...config.models.flatMap((m) => Object.values(m.headers ?? {}))]) { - if (template !== undefined) resolveCustomValue(template, (name) => { - const value = this.key(name); - values.set(name, value); - return value; - }); - } - } - const fingerprint = JSON.stringify([...values]); - if (this.fingerprint === fingerprint) return; - const lookup = (name: string) => values.get(name); - const providers = this.configs.map((config) => createCustomProvider(config, lookup)); - const ready = new Map(this.configs.map((config) => [config.id, customProviderAuth(config, lookup) !== undefined])); - // Everything validates/builds before the synchronous publication begins. - for (const id of this.registered) models.deleteProvider(id); - this.registered = new Set(providers.map((p) => p.id)); - for (const provider of providers) models.setProvider(provider); - this.ready = ready; - this.fingerprint = fingerprint; - this.error = undefined; - } catch (error) { - for (const id of this.registered) models.deleteProvider(id); - this.registered.clear(); - this.ready.clear(); - this.fingerprint = undefined; - this.error = error instanceof CustomProviderConfigError ? error.message : "Unable to configure custom providers from models.json."; - } - } - - configured(provider: string): boolean | undefined { return this.ready.get(provider); } - providers(): { id: string; envVar: string; keyPresent: boolean }[] { - return [...this.ready].map(([id, keyPresent]) => ({ id, envVar: "", keyPresent })); - } - getError(): string | undefined { return this.error; } -} - -let source: CustomProviderSource | undefined; -export function installCustomProviderSource(key: (name: string) => string | undefined): void { - const next = new CustomProviderSource(readCustomProvidersFile, key); - configureProviderSource({ - sync: (models) => next.sync(models), - isCustom: (id) => next.configured(id) !== undefined, - }); - source = next; -} -/** Call piModels() before these reads to synchronize the snapshot. */ -export function customProviderRows() { return source?.providers() ?? []; } -export function customProviderConfigured(provider: string): boolean | undefined { return source?.configured(provider); } -export function customProvidersError(): string | undefined { return source?.getError(); } diff --git a/src/server/index.ts b/src/server/index.ts index 9f24424..bf93d54 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -49,3 +49,4 @@ export { claudeCodeInstallationResponse } from "./claude-code.ts"; // The custom endpoint module (registers into piModels() + probes for models). // No import side effects; the boot sync lives in stores.ts. export * from "./custom-provider.ts"; +export { customProviderGet, customProviderMutation } from "./custom-provider-http.ts"; diff --git a/src/server/keys.ts b/src/server/keys.ts index 46b2be3..5bb2b6a 100644 --- a/src/server/keys.ts +++ b/src/server/keys.ts @@ -54,19 +54,19 @@ import { } from "../core/auth-keys.ts"; import { authStore } from "../store/auth.ts"; import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts"; -import { piModels } from "../agent/models.ts"; -import { customProviderConfigured, installCustomProviderSource } from "./custom-providers.ts"; +import { customProviderAvailable, installCustomProviderSource } from "./custom-provider.ts"; import { claudeCodeAvailable } from "./claude-code-config.ts"; import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts"; +import { CUSTOM_AUTH_BINDING } from "../core/custom-provider-auth.ts"; // Shared HMR-stable provenance lets external CLIs exclude file-only injections // without importing this store-backed module into the agent layer. import { authEnvRecord as boot } from "../core/auth-env.ts"; -/** Is `id` a WRITABLE key name — curated catalog, model-provider env var, or - * the custom endpoint's reserved key (writable through the same seam). */ +/** Ordinary writable key names. Custom auth writes must use the atomic + * endpoint-binding seam, never the generic single-key API. */ export function isWritableAuthKey(id: string): boolean { - return isKnownAuthKey(id) || isProviderEnvVar(id) || id === CUSTOM_PROVIDER.envVar; + return id !== CUSTOM_AUTH_BINDING && id !== CUSTOM_PROVIDER.envVar && (isKnownAuthKey(id) || isProviderEnvVar(id)); } /** The settings page's provider dropdown: every pi-ai provider that takes @@ -92,7 +92,7 @@ export function envKeysIgnored(): boolean { */ export function initAuthEnv(): void { const record = boot(); - const fileVars = authStore.authVars(); + const fileVars = Object.fromEntries(Object.entries(authStore.authVars()).filter(([name]) => name !== CUSTOM_AUTH_BINDING)); const managed = new Set([ ...Object.keys(fileVars), ...AUTH_KEYS.map((k) => k.id), @@ -100,6 +100,7 @@ export function initAuthEnv(): void { // an env-exported OPENAI_API_KEY with a file copy gets honest shadow // semantics, exactly like the curated vars. ...AUTH_PROVIDER_KEYS.map((p) => p.envVar), + CUSTOM_PROVIDER.envVar, ]); if (envKeysIgnored()) { // THE FRESH-USER SWITCH: delete every managed env var (re-run on @@ -127,7 +128,7 @@ export function initAuthEnv(): void { /** Is this env-var name one the keys system manages (curated, provider, * or held in the file)? */ function isManagedName(name: string): boolean { - return isWritableAuthKey(name) || name in authStore.authVars(); + return name !== CUSTOM_AUTH_BINDING && (isWritableAuthKey(name) || name in authStore.authVars()); } /** @@ -149,6 +150,7 @@ export function resolveKeyStatuses( } const rows: AuthKeyStatus[] = []; for (const id of ids) { + if (id === CUSTOM_AUTH_BINDING) continue; const envValue = env[id]; const fileValue = fileVars[id]; const isRealEnv = realEnv.has(id); @@ -170,7 +172,7 @@ export function resolveKeyStatuses( /** The GET view: every curated key (present or not) + unknown file vars. */ export function keyStatuses(): AuthKeyStatus[] { - return resolveKeyStatuses(authStore.authVars(), process.env, boot().realEnv, AUTH_PROVIDER_ENV_VARS); + return resolveKeyStatuses(authStore.authVars(), process.env, boot().realEnv, new Set([...AUTH_PROVIDER_ENV_VARS, CUSTOM_PROVIDER.envVar])); } /** The live value for one var, env-first (the env-wins rule) — the canonical @@ -178,6 +180,7 @@ export function keyStatuses(): AuthKeyStatus[] { * STT route was the one cold-boot hole: a first-ever request to it would * otherwise read env before any fill-gap ran). */ export function keyValue(id: string): string | undefined { + if (id === CUSTOM_AUTH_BINDING) return undefined; // The fresh-user switch: ONLY the file store counts (belt-and- // suspenders — the boot deletion already emptied the env; this guards // any env key reintroduced after boot). @@ -192,9 +195,7 @@ export function keyValue(id: string): string | undefined { * (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take * no key at all: nothing to gate, present by definition. */ export function providerKeyPresent(provider: string): boolean { - piModels(); // Synchronize hand-edited custom configuration before admission. - const custom = customProviderConfigured(provider); - if (custom !== undefined) return custom; + if (provider === CUSTOM_PROVIDER.id) return customProviderAvailable(); // Explicit opt-in + installation only, never authentication readiness. // Consulted at admission, not inside already-running agent loops. if (provider === CLAUDE_CODE_PROVIDER) return claudeCodeAvailable(); @@ -210,10 +211,8 @@ export function providerKeyPresent(provider: string): boolean { * reports shadowedByEnv so the page can explain it). */ export function saveKey(id: string, value: string): { ok: true; status: AuthKeyStatus } | { ok: false; error: string } { - const shape = - isProviderEnvVar(id) || id === CUSTOM_PROVIDER.envVar - ? genericProviderKeyGuard(id, value) - : validateKeyShape(id, value); + if (id === CUSTOM_PROVIDER.envVar || id === CUSTOM_AUTH_BINDING) return { ok: false, error: "Use the custom endpoint editor" }; + const shape = isProviderEnvVar(id) ? genericProviderKeyGuard(id, value) : validateKeyShape(id, value); if (!shape.ok) return { ok: false, error: shape.error }; const trimmed = value.trim(); authStore.setAuthVar(id, trimmed); @@ -232,6 +231,7 @@ export function saveKey(id: string, value: string): { ok: true; status: AuthKeyS * wins) and the status shows the key still present from env. */ export function deleteKey(id: string): AuthKeyStatus { + if (id === CUSTOM_PROVIDER.envVar || id === CUSTOM_AUTH_BINDING) throw new Error("Use the custom endpoint editor"); authStore.deleteAuthVar(id); const record = boot(); if (record.fillGap.has(id) && !record.realEnv.has(id)) { @@ -244,4 +244,4 @@ export function deleteKey(id: string): AuthKeyStatus { // Reflect the file into the env at first import (idempotent; the module is // imported by every route/handler path that touches keys). initAuthEnv(); -installCustomProviderSource(keyValue); +installCustomProviderSource(); diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index fa24801..44883dd 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -20,7 +20,7 @@ import { clampThinkingLevel, getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { piModels } from "../agent/models.ts"; -import { customProviderRows, customProvidersError } from "./custom-providers.ts"; +import { customProviderAvailable } from "./custom-provider.ts"; import { chatGPTConnected, chatGPTModel, chatGPTModels, refreshChatGPTModels } from "../agent/chatgpt-bridge.ts"; import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts"; import { nativeSubscriptionId, nativeSubscriptionConnected, nativeSubscriptionModel, nativeSubscriptionModels, refreshSubscriptionModels } from "../agent/subscription-bridge.ts"; @@ -155,11 +155,10 @@ export function catalogProviders(): CatalogProvider[] { { id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() }, { id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: claudeCodeAvailable() }, ...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })), - ...customProviderRows(), // The custom endpoint: a row only when usable (base URL set). keyPresent // reflects usability, not key presence (keyless local servers are allowed). ...(customProviderUsable(effectiveSettings().customProvider) - ? [{ id: CUSTOM_PROVIDER.id, envVar: CUSTOM_PROVIDER.envVar, keyPresent: true }] + ? [{ id: CUSTOM_PROVIDER.id, envVar: CUSTOM_PROVIDER.envVar, keyPresent: customProviderAvailable() }] : []), ]).sort((a, b) => a.id.localeCompare(b.id)); } @@ -180,7 +179,7 @@ export function toModelDto(model: Model): ModelDto { reasoning: model.reasoning, input: [...model.input], supportedLevels: getSupportedThinkingLevels(model), - cost: model.provider === CLAUDE_CODE_PROVIDER ? null : { + cost: model.provider === CLAUDE_CODE_PROVIDER || model.provider === CUSTOM_PROVIDER.id ? null : { input: model.cost.input, output: model.cost.output, cacheRead: model.cost.cacheRead, @@ -231,7 +230,6 @@ export function modelCatalog(): ModelCatalogResponse { rolePins: catalogRolePins(), slots: catalogSlots(), providers: catalogProviders(), - ...(customProvidersError() ? { customProvidersError: customProvidersError() } : {}), }; } diff --git a/src/server/pricing.ts b/src/server/pricing.ts index 1454c11..097669c 100644 --- a/src/server/pricing.ts +++ b/src/server/pricing.ts @@ -4,6 +4,7 @@ */ import { calculateCost, type Usage } from "@earendil-works/pi-ai"; import { piModels } from "../agent/models.ts"; +import { CUSTOM_PROVIDER } from "../core/auth-keys.ts"; import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts"; import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "../core/contracts.ts"; import { isSubscriptionModel, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts"; @@ -12,7 +13,7 @@ function catalogModel(id: string) { const colon = id.indexOf(":"); // Native CLI routing determines billing. Adapter zero-rate placeholders // must never become an apparent $0 estimate or subscription classification. - if (colon < 0 || isSubscriptionModel(id) || id.slice(0, colon) === CLAUDE_CODE_PROVIDER) return undefined; + if (colon < 0 || isSubscriptionModel(id) || [CLAUDE_CODE_PROVIDER, CUSTOM_PROVIDER.id].includes(id.slice(0, colon))) return undefined; return piModels().getModel(id.slice(0, colon), id.slice(colon + 1)); } diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index faad497..e84a7c9 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -400,7 +400,7 @@ export function parseDictationSettingsBody(body: unknown): SettingsBodyResult> { @@ -430,7 +430,7 @@ export function parseCustomModelOverridesBody( if (typeof entry.reasoning !== "boolean") return { ok: false, error: `reasoning for ${id} must be true or false` }; pin.reasoning = entry.reasoning; } - out[id] = pin; + Object.defineProperty(out, id, { value: pin, enumerable: true, configurable: true, writable: true }); } return { ok: true, settings: out }; } diff --git a/src/server/stores.ts b/src/server/stores.ts index 27e6c3e..2e543b1 100644 --- a/src/server/stores.ts +++ b/src/server/stores.ts @@ -36,4 +36,4 @@ export function effectiveSettings(): EffectiveSettings { // Boot: register the custom endpoint (when configured) into piModels(); the // POST /api/custom-provider route re-syncs after every connect. -syncCustomProvider(effectiveSettings().customProvider); +syncCustomProvider(); diff --git a/src/store/auth.ts b/src/store/auth.ts index 2fe6edd..4985cb4 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -7,9 +7,10 @@ * * The server (src/server/keys.ts) is the only intended writer: it applies * shape validation before setAuthVar and env precedence on top of - * authVars(). This adapter persists what it is given, never throws - * (absent/corrupt file = {} = "no keys yet", the documented first-boot - * state, matching settings doctrine), and preserves unknown vars verbatim + * authVars(). Ordinary reads never throw (absent/corrupt file = {} = + * "no keys yet"); custom-only pair reads/writes below are deliberately + * strict so an unreadable private record is never overwritten as {}. + * Ordinary writes preserve unknown string vars verbatim * (forward-compat: future entries — e.g. a plans-login token — ride along * untouched until a catalog entry adopts them). */ @@ -18,6 +19,8 @@ import fs from "node:fs"; import path from "node:path"; import { AUTH_FILE } from "../../config.ts"; import type { AuthStore } from "../core/ports.ts"; +import { CUSTOM_PROVIDER } from "../core/auth-keys.ts"; +import { CUSTOM_AUTH_BINDING, parseCustomAuthBinding, type CustomAuthBinding } from "../core/custom-provider-auth.ts"; /** The flat string→string map, or {} for absent/corrupt/non-object. */ function readMap(): Record { @@ -36,7 +39,7 @@ function readMap(): Record { * leave a 0644 file (secrets) or a truncated JSON (looks like "no keys"). * tmp-in-same-dir + rename keeps the swap atomic; chmod BEFORE the * rename so the mode never exists relaxed on the final path. */ -function writeMap(map: Record): void { +function writeMap(map: Record): void { const dir = path.dirname(AUTH_FILE); fs.mkdirSync(dir, { recursive: true }); const tmp = path.join(dir, `.auth.json.tmp-${process.pid}`); @@ -50,14 +53,50 @@ export const authStore: AuthStore = { return readMap(); }, setAuthVar(name, value) { + assertOrdinaryVar(name); const map = readMap(); map[name] = value; writeMap(map); }, deleteAuthVar(name) { + assertOrdinaryVar(name); const map = readMap(); if (!(name in map)) return; // absent = silent success, idempotent delete map[name]; writeMap(map); }, }; + +/** Custom writes must never turn an unreadable/corrupt private file into {}. + * Other providers retain their existing adapter semantics. Unknown JSON values + * survive custom-only updates, not just the ordinary string projection. */ +function readCustomMap(): Record { + let text: string; + try { text = fs.readFileSync(AUTH_FILE, "utf8"); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; throw new Error("Custom authorization storage unavailable"); } + let raw: unknown; + try { raw = JSON.parse(text); } catch { throw new Error("Custom authorization storage unavailable"); } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Custom authorization storage unavailable"); + const map = raw as Record; + if (Object.hasOwn(map, CUSTOM_AUTH_BINDING) && !parseCustomAuthBinding(map[CUSTOM_AUTH_BINDING])) throw new Error("Invalid custom authorization record"); + if (Object.hasOwn(map, CUSTOM_PROVIDER.envVar) && typeof map[CUSTOM_PROVIDER.envVar] !== "string") throw new Error("Invalid custom credential record"); + return map; +} +function assertOrdinaryVar(name: string): void { + if (name === CUSTOM_AUTH_BINDING || name === CUSTOM_PROVIDER.envVar) throw new Error("Use the custom endpoint editor"); +} +/** One immutable file snapshot. Keyless callers never invoke fileKey(). */ +export function readCustomAuth() { + const map = readCustomMap(); + return { binding: parseCustomAuthBinding(map[CUSTOM_AUTH_BINDING]), fileKey: () => map[CUSTOM_PROVIDER.envVar] as string | undefined }; +} +/** Replace just the custom pair in ONE existing atomic record write. */ +export function writeCustomAuth(binding: CustomAuthBinding | undefined, fileKey: string | undefined): void { + const map = readCustomMap(); + if (binding && !parseCustomAuthBinding(JSON.stringify(binding))) throw new Error("Invalid custom authorization record"); + if (fileKey === undefined) delete map[CUSTOM_PROVIDER.envVar]; + else Object.defineProperty(map, CUSTOM_PROVIDER.envVar, { value: fileKey, enumerable: true, configurable: true, writable: true }); + if (binding === undefined) delete map[CUSTOM_AUTH_BINDING]; + else Object.defineProperty(map, CUSTOM_AUTH_BINDING, { value: JSON.stringify(binding), enumerable: true, configurable: true, writable: true }); + writeMap(map); +} diff --git a/src/store/custom-providers.ts b/src/store/custom-providers.ts deleted file mode 100644 index 2245823..0000000 --- a/src/store/custom-providers.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** Read-only adapter. Configuration editing/UI is deliberately a later phase. */ -import fs from "node:fs"; -import { CUSTOM_MODELS_FILE } from "../../config.ts"; - -export function readCustomProvidersFile(): { text?: string; error?: string } { - try { return { text: fs.readFileSync(CUSTOM_MODELS_FILE, "utf8") }; } - catch (error) { - return (error as NodeJS.ErrnoException).code === "ENOENT" - ? { text: '{"providers":{}}' } - : { error: "Unable to read models.json." }; - } -} diff --git a/tests/custom-endpoint.test.ts b/tests/custom-endpoint.test.ts index d477b79..5aea909 100644 --- a/tests/custom-endpoint.test.ts +++ b/tests/custom-endpoint.test.ts @@ -26,7 +26,6 @@ import { buildOpenAiCompatibleProvider, discoverModels, mergeManualModels, - syncCustomProvider, } from "../src/server/custom-provider.ts"; import { piModels } from "../src/agent/models.ts"; import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; @@ -92,13 +91,13 @@ describe("sanitizeUserSettings — customProvider", () => { // ─── per-model pins: the read side + THE prune law ─────────────────────────── describe("sanitizeUserSettings / sanitizeCustomModelOverrides — pins", () => { - it("keeps well-formed pins, clamps token counts up to the floor, keeps reasoning:false", () => { + it("keeps all positive integer pins and reasoning:false", () => { const out = sanitizeUserSettings({ customProvider: { baseUrl: "https://h/v1", overrides: { llama: { context: 131072, maxTokens: 8192, reasoning: true }, - qwen: { context: 10 }, // under the floor → clamped up (clamp-never-reject on read) + qwen: { context: 10 }, // Small positive budgets are preserved. quiet: { reasoning: false }, // a real pin, not an absent one junk: { context: "big", maxTokens: -1, reasoning: "yes" }, // every field junk " ": { context: 4096 }, // no usable id @@ -108,7 +107,7 @@ describe("sanitizeUserSettings / sanitizeCustomModelOverrides — pins", () => { }); assert.deepEqual(out?.customProvider?.overrides, { llama: { context: 131072, maxTokens: 8192, reasoning: true }, - qwen: { context: CUSTOM_MODEL_PIN_FLOOR }, + qwen: { context: 10 }, quiet: { reasoning: false }, }); }); @@ -281,11 +280,11 @@ describe("buildOpenAiCompatibleProvider / syncCustomProvider", () => { assert.deepEqual(getSupportedThinkingLevels(plain!), ["off"]); }); - it("registers on a usable sync and removes when the base URL clears", () => { - syncCustomProvider({ baseUrl: "http://h/v1", models: [{ id: "live" }] }); + it("registers the native provider in the shared collection", () => { + piModels().setProvider(buildOpenAiCompatibleProvider({ baseUrl: "http://h/v1", models: [{ id: "live" }] })); assert.ok(piModels().getProvider(CUSTOM_PROVIDER.id), "provider registered"); assert.ok(piModels().getModel(CUSTOM_PROVIDER.id, "live"), "model resolvable"); - syncCustomProvider({ baseUrl: "", models: [{ id: "live" }] }); + piModels().deleteProvider(CUSTOM_PROVIDER.id); assert.equal(piModels().getProvider(CUSTOM_PROVIDER.id), undefined, "provider removed"); }); }); diff --git a/tests/custom-provider-boundaries.test.ts b/tests/custom-provider-boundaries.test.ts new file mode 100644 index 0000000..2ea1006 --- /dev/null +++ b/tests/custom-provider-boundaries.test.ts @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { after, beforeEach, test, mock } from "node:test"; + +const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-boundaries-")); +const originalEnv = process.env; +const originalFetch = globalThis.fetch; +process.env = { ...(originalEnv.NODE_TEST_CONTEXT ? { NODE_TEST_CONTEXT: originalEnv.NODE_TEST_CONTEXT } : {}), ...(originalEnv.NODE_OPTIONS ? { NODE_OPTIONS: originalEnv.NODE_OPTIONS } : {}), NODE_ENV: "test", HOME: scratch, LH_DATA_DIR: scratch, LH_AUTH_FILE: path.join(scratch, "auth.json"), LH_IGNORE_ENV_KEYS: "1" }; +const route = await import("../app/api/custom-provider/route.ts"); +const keys = await import("../src/server/keys.ts"); +const { authEnvRecord } = await import("../src/core/auth-env.ts"); +const { CUSTOM_PROVIDER } = await import("../src/core/auth-keys.ts"); +const { authorizeFixture } = await import("./helpers/custom-provider-auth.ts"); +const { readCustomAuth } = await import("../src/store/auth.ts"); +const { persistCustomAuth } = await import("../src/server/custom-provider-auth.ts"); +const settingsFile = path.join(scratch, "settings.json"); +const seed = (cp: import("../src/core/settings.ts").CustomProviderSettings = { baseUrl: "http://old/v1", authMode: "keyless", models: [{ id: "m" }] }, key?: string) => fs.writeFileSync(settingsFile, JSON.stringify({ version: 1, customProvider: authorizeFixture(cp, key) })); +const setFileKey = (key: string) => { const auth = readCustomAuth(); persistCustomAuth({ ...auth.binding!, fileKeyBaseUrl: auth.binding!.baseUrl }, key); }; +const current = () => JSON.parse(fs.readFileSync(settingsFile, "utf8")); +const get = (inspect = false) => route.GET(new Request(`http://localhost/api/custom-provider${inspect ? "?authMode=apiKey" : ""}`)); +function mutate(body: unknown, method = "POST", headers: Record = {}) { + return route.POST(new Request("http://localhost/api/custom-provider", { method, headers: { origin: "http://localhost", "content-type": "application/json", "if-match": get().headers.get("etag")!, ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })); +} +let requests: { url: string; authorization: string | null }[]; +beforeEach(() => { + mock.restoreAll(); + process.env.LH_IGNORE_ENV_KEYS = "1"; + delete process.env.LH_DESKTOP_SERVER; delete process.env.LH_DESKTOP_API_SECRET; + fs.writeFileSync(path.join(scratch, "auth.json"), "{}"); seed(); + requests = []; + globalThis.fetch = async (url, options) => { + requests.push({ url: String(url), authorization: new Headers(options?.headers).get("authorization") }); + assert.equal(options?.redirect, "manual"); + assert.ok(options?.signal instanceof AbortSignal); + return Response.json({ data: [{ id: "m" }] }); + }; +}); +after(() => { mock.restoreAll(); process.env = originalEnv; globalThis.fetch = originalFetch; fs.rmSync(scratch, { recursive: true, force: true }); }); + +test("keyless probe never forwards a stored key, and clearing removes authentication", async () => { + setFileKey("private-old"); + assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "keyless", key: "do-not-use-this" })).status, 200); + assert.equal(requests[0]?.authorization, null); + assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), undefined); + assert.equal(current().customProvider.authMode, "keyless"); +}); +test("new keys have no arbitrary minimum; the exact probed key is stored", async () => { + assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", key: " k " })).status, 200); + assert.equal(requests[0]?.authorization, "Bearer k"); + assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "k"); + const body = await get().text(); assert.ok(!body.includes("Bearer")); +}); +test("changing destination cannot silently reuse an existing credential", async () => { + setFileKey("private-old"); + const payload = { baseUrl: "http://new/v1", authMode: "apiKey" }; + assert.equal((await mutate(payload)).status, 400); assert.equal(requests.length, 0); + assert.equal((await mutate({ ...payload, reuseKey: true })).status, 200); + assert.equal(requests[0]?.authorization, "Bearer private-old"); +}); +test("same-URL unbound development key needs existing explicit reuse consent", async () => { + seed({ baseUrl: "http://old/v1", authMode: "apiKey", models: [{ id: "m" }] }, "legacy-key"); + const { CUSTOM_AUTH_BINDING } = await import("../src/core/custom-provider-auth.ts"); + const file = path.join(scratch, "auth.json"); const map = JSON.parse(fs.readFileSync(file, "utf8")); delete map[CUSTOM_AUTH_BINDING]; fs.writeFileSync(file, JSON.stringify(map)); + assert.equal((await get().json()).keyReuseRequired, true); + assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey" })).status, 400); assert.equal(requests.length, 0); + assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey", reuseKey: true })).status, 200); + assert.equal(requests[0]?.authorization, "Bearer legacy-key"); assert.equal((await get().json()).keyReuseRequired, false); +}); +test("generic custom/reserved key writes cannot bypass the atomic binding seam", async () => { + const ordinary = await import("../app/api/keys/[id]/route.ts"); + const { CUSTOM_AUTH_BINDING } = await import("../src/core/custom-provider-auth.ts"); + const { authStore } = await import("../src/store/auth.ts"); + const before = fs.readFileSync(path.join(scratch, "auth.json"), "utf8"); + for (const id of [CUSTOM_PROVIDER.envVar, CUSTOM_AUTH_BINDING]) { + const context = { params: Promise.resolve({ id }) }; + assert.equal((await ordinary.PUT(new Request("http://localhost", { method: "PUT", body: JSON.stringify({ value: "bypass" }) }), context)).status, 404); + assert.equal((await ordinary.DELETE(new Request("http://localhost", { method: "DELETE" }), context)).status, 404); + assert.equal(keys.saveKey(id, "bypass").ok, false); assert.throws(() => keys.deleteKey(id)); + assert.throws(() => authStore.setAuthVar(id, "bypass")); assert.throws(() => authStore.deleteAuthVar(id)); + } + assert.equal(fs.readFileSync(path.join(scratch, "auth.json"), "utf8"), before); + assert.ok(!keys.keyStatuses().some(row => row.id === CUSTOM_AUTH_BINDING)); assert.equal(process.env[CUSTOM_AUTH_BINDING], undefined); +}); +test("same target can retain its effective key", async () => { + seed({ baseUrl: "http://old/v1", authMode: "apiKey", models: [{ id: "m" }] }, "retained"); + assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey" })).status, 200); + assert.equal(requests[0]?.authorization, "Bearer retained"); +}); +test("environment ownership is shown; consent uses the environment key in probe and inference", async () => { + process.env.LH_IGNORE_ENV_KEYS = "0"; + process.env[CUSTOM_PROVIDER.envVar] = "environment-secret"; + authEnvRecord().realEnv.add(CUSTOM_PROVIDER.envVar); + try { + assert.equal((await get(true).json()).keySource, "env"); + assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", key: "shadowed" })).status, 400); + assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", reuseKey: true })).status, 200); + assert.equal(requests[0]?.authorization, "Bearer environment-secret"); + assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "environment-secret"); + assert.ok(!(await get().text()).includes("environment-secret")); + assert.equal((await mutate({ baseUrl: "http://other/v1", authMode: "keyless" })).status, 200); + assert.equal(requests.at(-1)?.authorization, null); + } finally { authEnvRecord().realEnv.delete(CUSTOM_PROVIDER.envVar); delete process.env[CUSTOM_PROVIDER.envVar]; } +}); +test("cross-origin/opaque/missing-origin and non-JSON writes cannot probe or mutate", async () => { + for (const origin of ["http://evil", "null", ""]) assert.equal((await mutate({ baseUrl: "http://new", authMode: "keyless" }, "POST", { origin })).status, 403); + assert.equal((await mutate({}, "POST", { "content-type": "text/plain" })).status, 415); + assert.equal((await mutate({}, "POST", { "sec-fetch-site": "cross-site" })).status, 403); + assert.equal(requests.length, 0); +}); +test("standalone internal URL uses the addressed Host origin, never forwarded origins", async () => { + const headers = { "content-type": "application/json", "if-match": get().headers.get("etag")!, host: "127.0.0.1:3317", origin: "http://127.0.0.1:3317" }; + assert.equal((await route.DELETE(new Request("http://localhost:3317/api/custom-provider", { method: "DELETE", headers }))).status, 200); + assert.equal((await mutate({}, "DELETE", { "x-forwarded-host": "evil", origin: "http://evil" })).status, 403); +}); +test("empty DELETE streams normalize consistently without accepting malformed JSON", async () => { + const headers = { "content-type": "application/json", "if-match": get().headers.get("etag")!, origin: "http://localhost" }; + const init: RequestInit & { duplex: "half" } = { method: "DELETE", headers, body: new ReadableStream({ start(controller) { controller.close(); } }), duplex: "half" }; + assert.equal((await route.DELETE(new Request("http://localhost/api/custom-provider", init))).status, 200); + assert.equal((await route.DELETE(new Request("http://localhost/api/custom-provider", { method: "DELETE", headers: { ...headers, "if-match": get().headers.get("etag")! }, body: "{" }))).status, 400); + assert.equal((await route.POST(new Request("http://localhost/api/custom-provider", { method: "POST", headers, body: "" }))).status, 400); +}); +test("desktop capability remains mandatory in desktop mode", async () => { + process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "x".repeat(64); + assert.equal((await mutate({}, "DELETE")).status, 403); + assert.equal((await mutate(undefined, "DELETE", { origin: "null", "x-learnharness-desktop": "x".repeat(64) })).status, 200); +}); +test("forbidden URL components are rejected before probing", async () => { + for (const baseUrl of ["ftp://h", "https://user:pass@h/v1", "http://h/v1?q=secret", "http://h/v1#x", "http://h/v1?", "http://h/v1#"]) assert.equal((await mutate({ baseUrl, authMode: "keyless" })).status, 400); + assert.equal(requests.length, 0); +}); +test("redirects/auth failures/model and byte limits cannot be bypassed with typed IDs", async () => { + for (const answer of [ + () => new Response(null, { status: 302, headers: { location: "http://other" } }), + () => new Response("private response", { status: 401 }), + () => new Response("private response", { status: 403 }), + () => Response.json({ data: Array(10001).fill({ id: "m" }) }), + () => new Response("x".repeat(8 * 1024 * 1024 + 1)), + () => new Response("{}", { headers: { "content-length": String(8 * 1024 * 1024 + 1) } }), + ]) { + globalThis.fetch = async () => answer(); + const before = current(); + const res = await mutate({ baseUrl: "http://h/v1", authMode: "keyless", manualModels: "m" }); + assert.equal(res.status, 502); assert.deepEqual(current(), before); + assert.ok(!(await res.text()).includes("private response")); + } +}); +test("write size bounds are explicit for streamed bodies", async () => { + assert.equal((await mutate({ baseUrl: "x".repeat(65536), authMode: "keyless" })).status, 413); + assert.equal((await mutate({ padding: "x".repeat(65536) }, "DELETE")).status, 413); + assert.equal(requests.length, 0); +}); +test("stale save, pin edit and disconnect all return reload guidance without writing", async () => { + const etag = get().headers.get("etag")!; + seed({ baseUrl: "http://other/v1", authMode: "keyless", models: [{ id: "different" }] }); + const before = current(); + for (const method of ["POST", "PATCH", "DELETE"]) { + const res = await mutate({ baseUrl: "http://h", authMode: "keyless", overrides: {} }, method, { "if-match": etag }); + assert.equal(res.status, 409); assert.match(await res.text(), /Reload/); + } + assert.equal(requests.length, 0); assert.deepEqual(current(), before); +}); +test("after discovery preserves unrelated edits and rejects intervening custom edits", async () => { + globalThis.fetch = async () => { fs.writeFileSync(settingsFile, JSON.stringify({ ...current(), appearance: { mode: "dark" }, claudeCode: { enabled: true } })); return Response.json({ data: [{ id: "m" }] }); }; + assert.equal((await mutate({ baseUrl: "http://h", authMode: "keyless" })).status, 200); + assert.deepEqual(current().appearance, { mode: "dark" }); assert.equal(current().claudeCode.enabled, true); + globalThis.fetch = async () => { seed({ baseUrl: "http://changed", authMode: "keyless", models: [] }); return Response.json({ data: [{ id: "m" }] }); }; + assert.equal((await mutate({ baseUrl: "http://h", authMode: "keyless" })).status, 409); + assert.equal(current().customProvider.baseUrl, "http://changed"); +}); +test("after discovery rejects a changed effective key", async () => { + seed({ baseUrl: "http://old/v1", authMode: "apiKey", models: [{ id: "m" }] }, "before"); + globalThis.fetch = async () => { setFileKey("rotated"); return Response.json({ data: [{ id: "m" }] }); }; + assert.equal((await mutate({ baseUrl: "http://old/v1", authMode: "apiKey" })).status, 409); + assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "rotated"); +}); +test("offline pins accept 512/1, retain support declaration, and do not probe", async () => { + const res = await mutate({ overrides: { m: { context: 1, maxTokens: 512, reasoning: false } } }, "PATCH"); + assert.equal(res.status, 200); assert.equal(requests.length, 0); + assert.deepEqual(current().customProvider.overrides.m, { context: 1, maxTokens: 512, reasoning: false }); +}); +test("settings-write failure restores previous key/config and reports failure", async () => { + setFileKey("old-key"); + const before = current(); + const write = fs.writeFileSync; + mock.method(fs, "writeFileSync", (...args: Parameters) => { if (args[0] === settingsFile) throw new Error("private-io-details"); return write(...args); }); + const res = await mutate({ baseUrl: "http://new", authMode: "apiKey", key: "new-key" }); + assert.equal(res.status, 500); assert.ok(!(await res.text()).includes("private-io-details")); + assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "old-key"); assert.deepEqual(current(), before); +}); diff --git a/tests/custom-provider-lifecycle.test.ts b/tests/custom-provider-lifecycle.test.ts new file mode 100644 index 0000000..d4b259c --- /dev/null +++ b/tests/custom-provider-lifecycle.test.ts @@ -0,0 +1,129 @@ +/** Native Pi payloads with synthetic transport and owned scratch files only. */ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { after, beforeEach, test } from "node:test"; +import { createCanvas } from "@napi-rs/canvas"; +import { normalizeContext, fauxAssistantMessage, fauxToolCall, type Message } from "@earendil-works/pi-ai"; + +const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-lifecycle-")); +const originalEnv = process.env; +const originalFetch = globalThis.fetch; +process.env = { ...(originalEnv.NODE_TEST_CONTEXT ? { NODE_TEST_CONTEXT: originalEnv.NODE_TEST_CONTEXT } : {}), ...(originalEnv.NODE_OPTIONS ? { NODE_OPTIONS: originalEnv.NODE_OPTIONS } : {}), NODE_ENV: "test", HOME: scratch, LH_DATA_DIR: scratch, LH_AUTH_FILE: path.join(scratch, "auth.json"), LH_IGNORE_ENV_KEYS: "1" }; +const { writeSettings, readSettings } = await import("../src/store/settings.ts"); +const { piEngineForSpec, piModels } = await import("../src/agent/models.ts"); +await import("../src/server/keys.ts"); +const { readCustomAuth } = await import("../src/store/auth.ts"); +const { persistCustomAuth, customAuthState } = await import("../src/server/custom-provider-auth.ts"); +const { authorizeFixture } = await import("./helpers/custom-provider-auth.ts"); +const { modelCatalog, catalogContextLimits } = await import("../src/server/model-catalog.ts"); +const { callCost } = await import("../src/server/pricing.ts"); +const { createReadTool } = await import("../src/agent/tools/read.ts"); +const { installCustomProviderSource } = await import("../src/server/custom-provider.ts"); +let requests: { url: string; auth: string | null; body: Record }[]; +function configure(baseUrl = "http://first/v1", reasoning = false, maxTokens = 512, authMode: "keyless" | "apiKey" = "apiKey") { + const key = authMode === "apiKey" ? readCustomAuth().fileKey() ?? "first-key" : undefined; + writeSettings({ version: 1, customProvider: authorizeFixture({ baseUrl, authMode, models: [{ id: "m", context: 16384, maxTokens, reasoning }] }, key) }); +} +function answer() { + return new Response(`data: ${JSON.stringify({ id: "fake", choices: [{ index: 0, delta: { role: "assistant", content: "ok" }, finish_reason: null }] })}\n\ndata: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1 } })}\n\ndata: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" } }); +} +function capture(url: unknown, init?: RequestInit) { + requests.push({ url: String(url), auth: new Headers(init?.headers).get("authorization"), body: JSON.parse(String(init?.body)) }); +} +const context = (messages: Message[] = [{ role: "user", content: "synthetic", timestamp: 0 }]) => normalizeContext({ messages }); +const run = (engine: ReturnType, messages?: Message[]) => engine.streamFn(engine.model, context(messages), {}).result(); +beforeEach(() => { + requests = []; fs.writeFileSync(path.join(scratch, "auth.json"), "{}"); configure(); + globalThis.fetch = async (url, init) => { capture(url, init); return answer(); }; +}); +after(() => { process.env = originalEnv; globalThis.fetch = originalFetch; fs.rmSync(scratch, { recursive: true, force: true }); }); + +test("native requests revalidate endpoint/model/key/limits with no fallback and retain in-flight setup", async () => { + const engine = piEngineForSpec({ provider: "custom", id: "m" }); + let started!: () => void; const ready = new Promise((r) => { started = r; }); + let release!: () => void; + globalThis.fetch = async (url, init) => { capture(url, init); started(); return new Promise((resolve) => { release = () => resolve(answer()); }); }; + const inFlight = run(engine); await ready; + configure("http://second/v1", false, 1024); persistCustomAuth(readCustomAuth().binding, "second-key"); + globalThis.fetch = async (url, init) => { capture(url, init); return answer(); }; + assert.equal((await run(engine)).stopReason, "stop"); + release(); assert.equal((await inFlight).stopReason, "stop"); + assert.match(requests[0]!.url, /^http:\/\/first\/v1\/chat\/completions$/); + assert.equal(requests[0]!.auth, "Bearer first-key"); assert.equal(requests[0]!.body.max_completion_tokens, 512); + assert.match(requests[1]!.url, /^http:\/\/second\/v1\/chat\/completions$/); + assert.equal(requests[1]!.auth, "Bearer second-key"); assert.equal(requests[1]!.body.max_completion_tokens, 1024); + writeSettings({ version: 1, customProvider: { ...readSettings()!.customProvider, models: [{ id: "other" }] } }); + assert.ok(piModels().getProvider("custom"), "provider stays authorized while this model is removed"); + assert.equal((await run(engine)).stopReason, "error"); + writeSettings({ version: 1 }); assert.equal((await run(engine)).stopReason, "error"); + assert.equal(requests.length, 2); + assert.ok(piModels().getProvider("openai"), "native route survives custom removal"); +}); +test("current key changes without configuration edits are reflected on the next operation", async () => { + const engine = piEngineForSpec({ provider: "custom", id: "m" }); + await run(engine); persistCustomAuth(readCustomAuth().binding, "rotated-key"); await run(engine); + assert.deepEqual(requests.map((r) => r.auth), ["Bearer first-key", "Bearer rotated-key"]); +}); +test("original saved thinking preference recovers after capability expansion and contracts again", async () => { + const engine = piEngineForSpec({ provider: "custom", id: "m", thinkingLevel: "high" }); + assert.equal(engine.level, "off"); + await run(engine); configure("http://first/v1", true); await run(engine); configure(); await run(engine); + assert.deepEqual(requests.map((r) => r.body.reasoning_effort), [undefined, "high", undefined]); +}); +test("unset preference reselects the existing default against current reasoning metadata", async () => { + const engine = piEngineForSpec({ provider: "custom", id: "m" }); + configure("http://first/v1", true); await run(engine); + assert.equal(requests[0]!.body.reasoning_effort, "medium"); + configure(); await run(engine); assert.equal(requests[1]!.body.reasoning_effort, undefined); +}); +test("catalog capabilities remain reported and prices unknown despite runtime image permission", () => { + const engine = piEngineForSpec({ provider: "custom", id: "m" }); + assert.deepEqual(engine.model.input, ["text", "image"]); + const dto = modelCatalog().models.find((m) => m.provider === "custom")!; + assert.deepEqual(dto.input, ["text"]); assert.equal(dto.cost, null); + assert.equal(callCost({ inputTokens: 100, outputTokens: 100 }, "custom:m"), null); + assert.equal(catalogContextLimits().get("custom:m"), 16384); +}); +const image = { type: "image" as const, data: createCanvas(1, 1).toBuffer("image/png").toString("base64"), mimeType: "image/png" }; +test("actual native encoder preserves USER and tool-result images under missing metadata, including after reload", async () => { + const engine = piEngineForSpec({ provider: "custom", id: "m" }); + const messages: Message[] = [ + { role: "user", content: [{ type: "text", text: "look" }, image], timestamp: 0 }, + fauxAssistantMessage([fauxToolCall("read", {}, { id: "read-1" })], { stopReason: "toolUse" }), + { role: "toolResult", toolCallId: "read-1", toolName: "read", content: [image], isError: false, timestamp: 1 }, + ]; + await run(engine, messages); configure("http://second/v1"); await run(engine, messages); + for (const request of requests) { + const body = JSON.stringify(request.body.messages); + assert.equal((body.match(/"type":"image_url"/g) ?? []).length, 2); + assert.equal((body.match(/data:image\/png;base64/g) ?? []).length, 2); + assert.ok(!body.includes("image omitted")); + } + assert.deepEqual(piModels().getModel("custom", "m")!.input, ["text"]); +}); +test("teacher-bound read returns image without contradictory omission note", async () => { + const file = path.join(scratch, "synthetic.png"); fs.writeFileSync(file, Buffer.from(image.data, "base64")); + const engine = piEngineForSpec({ provider: "custom", id: "m" }); + const result = await createReadTool(engine.model).execute("read-1", { path: file }); + assert.ok(result.content.some((block) => block.type === "image")); + assert.ok(!JSON.stringify(result).includes("image will be omitted")); +}); +test("backend image rejection surfaces once, never retries without images", async () => { + globalThis.fetch = async (url, init) => { capture(url, init); return Response.json({ error: { message: "images rejected by backend" } }, { status: 400 }); }; + const result = await run(piEngineForSpec({ provider: "custom", id: "m" }), [{ role: "user", content: [image], timestamp: 0 }]); + assert.equal(result.stopReason, "error"); assert.match(result.errorMessage ?? "", /images rejected/); assert.equal(requests.length, 1); +}); +test("existing engine refuses nonce mismatch on EVERY operation, without falling back to env", async () => { + const engine = piEngineForSpec({ provider: "custom", id: "m" }); await run(engine); + persistCustomAuth({ ...readCustomAuth().binding!, id: "b".repeat(32) }, "different-key"); + assert.equal((await run(engine)).stopReason, "error"); assert.equal(requests.length, 1); +}); +test("keyless publication never reads the credential lookup", async () => { + configure("http://first/v1", false, 512, "keyless"); + let credentialLookups = 0; + installCustomProviderSource((inspect, cp) => { if (inspect) credentialLookups++; return customAuthState(inspect, cp); }); + try { assert.equal((await run(piEngineForSpec({ provider: "custom", id: "m" }))).stopReason, "stop"); assert.equal(credentialLookups, 0); assert.equal(requests[0]!.auth, "Bearer not-needed"); } + finally { installCustomProviderSource(); } +}); diff --git a/tests/custom-provider-restart.test.ts b/tests/custom-provider-restart.test.ts new file mode 100644 index 0000000..1361c78 --- /dev/null +++ b/tests/custom-provider-restart.test.ts @@ -0,0 +1,81 @@ +/** No real endpoints/accounts. Actual process death and cold startup, not HMR. */ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { after, test } from "node:test"; +import { CUSTOM_AUTH_BINDING } from "../src/core/custom-provider-auth.ts"; +const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-restart-")); +after(() => fs.rmSync(root, { recursive: true, force: true })); +const base = "http://first.invalid/v1"; +function profile() { + const home = fs.mkdtempSync(path.join(root, "case-")); + const id = "a".repeat(32); + fs.writeFileSync(path.join(home, "settings.json"), JSON.stringify({ version: 1, appearance: { mode: "dark" }, customProvider: { baseUrl: base, authMode: "apiKey", credentialBindingId: id, models: [{ id: "m" }] } })); + fs.writeFileSync(path.join(home, "auth.json"), JSON.stringify({ CUSTOM_OPENAI_API_KEY: "fixture-old-key", OTHER_PROVIDER_KEY: "untouched", future: { preserve: true }, [CUSTOM_AUTH_BINDING]: JSON.stringify({ version: 1, id, baseUrl: base, authMode: "apiKey", fileKeyBaseUrl: base }) }), { mode: 0o600 }); + return home; +} +function child(home: string, mode: "mutate" | "inspect", action = "key", fault = "none", envKey?: string) { + const result = spawnSync(process.execPath, ["--import", "tsx", "tests/helpers/custom-provider-crash.ts", mode, action, fault], { cwd: process.cwd(), env: { PATH: process.env.PATH, HOME: home, USERPROFILE: home, NODE_ENV: "test", LH_DATA_DIR: home, LH_AUTH_FILE: path.join(home, "auth.json"), LH_IGNORE_ENV_KEYS: "0", ...(envKey ? { CUSTOM_OPENAI_API_KEY: envKey } : {}) }, encoding: "utf8", timeout: 15000 }); + assert.ifError(result.error); + assert.ok(result.status === 0 || result.status === 77, `${result.status}: ${result.stderr}`); + return { exit: result.status, data: result.stdout.trim() ? JSON.parse(result.stdout.trim().split("\n").at(-1)!) : undefined }; +} +function closed(home: string, envKey?: string) { + const { data } = child(home, "inspect", "key", "none", envKey); + assert.equal(data.enabled, false); assert.deepEqual(data.requests, []); assert.equal(data.metadataInEnv, false); + assert.ok(!data.statusIds.includes(CUSTOM_AUTH_BINDING)); +} +test("cold restart refuses the new auth/old URL gap and double-I/O failure, with or without env", () => { + for (const fault of ["after-auth", "double-io"]) { + const home = profile(); const result = child(home, "mutate", "key", fault); + if (fault === "after-auth") assert.equal(result.exit, 77); else assert.equal(result.data.status, 500); + closed(home); closed(home, "fixture-real-env"); + const auth = JSON.parse(fs.readFileSync(path.join(home, "auth.json"), "utf8")); + assert.equal(auth.OTHER_PROVIDER_KEY, "untouched"); assert.deepEqual(auth.future, { preserve: true }); + } +}); +test("failed auth publication retains old matching setup; landed settings plus restored auth fails closed", () => { + const home = profile(); assert.equal(child(home, "mutate", "key", "auth-before-rename").data.status, 500); + const old = child(home, "inspect").data; assert.equal(old.enabled, true); assert.equal(old.requests[0].url, base + "/chat/completions"); assert.equal(old.requests[0].authorization, "Bearer fixture-old-key"); + const other = profile(); assert.equal(child(other, "mutate", "key", "settings-landed").data.status, 500); closed(other); +}); +test("clear/disconnect half commits stay closed across restart even with genuine env keys", () => { + for (const action of ["keyless", "disconnect"]) { + for (const fault of ["after-auth", "double-io"]) { + const home = profile(); child(home, "mutate", action, fault, "fixture-real-env"); closed(home, "fixture-real-env"); + } + } +}); +test("successful keyless checks only binding; disconnect tombstone blocks latent env after restart", () => { + const home = profile(); assert.equal(child(home, "mutate", "keyless", "none", "fixture-real-env").data.status, 200); + const state = child(home, "inspect", "key", "none", "fixture-real-env").data; assert.equal(state.enabled, true); assert.equal(state.requests[0].authorization, "Bearer not-needed"); + assert.equal(child(home, "mutate", "disconnect", "none", "fixture-real-env").data.status, 200); closed(home, "fixture-real-env"); +}); +test("env reuse never reauthorizes a shadowed file key for another destination", () => { + const home = profile(); assert.equal(child(home, "mutate", "envMove", "none", "fixture-real-env").data.status, 200); + const live = child(home, "inspect", "key", "none", "fixture-real-env").data; assert.equal(live.enabled, true); assert.equal(live.requests[0].url, "http://second.invalid/v1/chat/completions"); assert.equal(live.requests[0].authorization, "Bearer fixture-real-env"); + closed(home); + const auth = JSON.parse(fs.readFileSync(path.join(home, "auth.json"), "utf8")); assert.equal(JSON.parse(auth[CUSTOM_AUTH_BINDING]).fileKeyBaseUrl, base); assert.equal(auth.CUSTOM_OPENAI_API_KEY, "fixture-old-key"); + assert.equal(child(home, "mutate", "reuse").data.status, 200); assert.equal(child(home, "inspect").data.requests[0].authorization, "Bearer fixture-old-key"); +}); +test("unbound development records require explicit manual reauthorization, never automatic migration", () => { + const home = profile(); const file = path.join(home, "auth.json"); const auth = JSON.parse(fs.readFileSync(file, "utf8")); delete auth[CUSTOM_AUTH_BINDING]; fs.writeFileSync(file, JSON.stringify(auth)); + closed(home); closed(home, "fixture-real-env"); + assert.equal(child(home, "mutate", "reuse").data.status, 200); assert.equal(child(home, "inspect").data.enabled, true); +}); +test("malformed/unreadable private snapshots are never rewritten or used, unrelated bytes survive", () => { + for (const raw of ['{"OTHER_PROVIDER_KEY":"do-not-erase",', '[]', JSON.stringify({ OTHER_PROVIDER_KEY: "do-not-erase", [CUSTOM_AUTH_BINDING]: "not-json" })]) { + const home = profile(); const file = path.join(home, "auth.json"); fs.writeFileSync(file, raw); + closed(home, "fixture-real-env"); assert.equal(child(home, "mutate").data.status, 500); assert.equal(fs.readFileSync(file, "utf8"), raw); + } + const home = profile(); const file = path.join(home, "auth.json"); const before = fs.readFileSync(file, "utf8"); + assert.equal(child(home, "mutate", "key", "unreadable").data.status, 500); assert.equal(fs.readFileSync(file, "utf8"), before); + assert.equal(child(home, "inspect", "key", "unreadable", "fixture-real-env").data.enabled, false); +}); +test("missing auth file initializes normally with explicit new key, preserving other settings", () => { + const home = profile(); fs.unlinkSync(path.join(home, "auth.json")); closed(home); + assert.equal(child(home, "mutate").data.status, 200); const live = child(home, "inspect").data; assert.equal(live.enabled, true); assert.equal(live.requests[0].authorization, "Bearer fixture-new-key"); + assert.deepEqual(JSON.parse(fs.readFileSync(path.join(home, "settings.json"), "utf8")).appearance, { mode: "dark" }); +}); diff --git a/tests/custom-provider-route.test.ts b/tests/custom-provider-route.test.ts index 6315ded..26c9d61 100644 --- a/tests/custom-provider-route.test.ts +++ b/tests/custom-provider-route.test.ts @@ -37,6 +37,7 @@ const CONNECTED = { version: 1, customProvider: { baseUrl: BASE, + authMode: "keyless", models: [{ id: "disc-1", context: 131072 }, { id: "disc-2" }], overrides: { "disc-1": { context: 200000 }, "disc-2": { maxTokens: 8192 } }, manualModels: ["m1"], @@ -59,8 +60,8 @@ function post(payload: unknown): Promise { return route.POST( new Request("http://localhost/api/custom-provider", { method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(payload), + headers: { "content-type": "application/json", origin: "http://localhost", "if-match": route.GET(new Request("http://localhost/api/custom-provider")).headers.get("etag")! }, + body: JSON.stringify({ authMode: payload && typeof payload === "object" && "key" in payload ? "apiKey" : "keyless", ...payload as object }), }), ); } diff --git a/tests/custom-providers.test.ts b/tests/custom-providers.test.ts deleted file mode 100644 index baf2080..0000000 --- a/tests/custom-providers.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -import assert from "node:assert/strict"; -import { after, describe, it } from "node:test"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { createModels, normalizeContext, type SimpleStreamOptions } from "@earendil-works/pi-ai"; -import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; -import { parseCustomProviders, resolveCustomValue } from "../src/core/custom-providers.ts"; -import { createCustomProvider, customProviderApis } from "../src/agent/custom-providers.ts"; -import { CLAUDE_CODE_PROVIDER } from "../src/core/contracts/claude-code.ts"; - -const zero = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; -function model(id = "chat", extra = {}) { - return { id, contextWindow: 8192, maxTokens: 1024, cost: zero, ...extra }; -} -function config(extra = {}, models = [model()]) { - return { providers: { gateway: { baseUrl: "http://example.invalid/custom/v1", api: "openai-completions", apiKey: "dummy", models, ...extra } } }; -} -function parse(value: unknown) { return parseCustomProviders(JSON.stringify(value), new Set(["openai", "chatgpt"]), customProviderApis); } - -const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-providers-")); -process.env.LH_DATA_DIR = temp; -process.env.LH_AUTH_FILE = path.join(temp, "auth.json"); -process.env.LH_IGNORE_ENV_KEYS = "1"; -fs.writeFileSync(process.env.LH_AUTH_FILE, JSON.stringify({ GW_KEY: "first-key", GW_HEADER: "first-header" })); -const { CUSTOM_MODELS_FILE, SETTINGS_FILE } = await import("../config.ts"); -const { modelCatalog, validateModelsSettings, clampModelsLevels, catalogContextLimits } = await import("../src/server/model-catalog.ts"); -const { piModels, piEngineForSpec } = await import("../src/agent/models.ts"); -const { providerKeyPresent, keyValue } = await import("../src/server/keys.ts"); -const { authStore } = await import("../src/store/auth.ts"); -const { CustomProviderSource, customProviderConfigured, installCustomProviderSource } = await import("../src/server/custom-providers.ts"); -const { callCost } = await import("../src/server/pricing.ts"); -after(() => fs.rmSync(temp, { recursive: true, force: true })); - -function save(value: unknown) { fs.writeFileSync(CUSTOM_MODELS_FILE, JSON.stringify(value)); } -function sseText(text = "ok") { - const chunks = [ - { choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }] }, - { choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 } }, - ]; - return new Response(chunks.map((chunk) => `data: ${JSON.stringify({ id: "test", object: "chat.completion.chunk", created: 1, model: "chat", ...chunk })}\n\n`).join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } }); -} - -describe("declarative custom-provider configuration", () => { - it("uses Pi metadata and provider/model inheritance without rewriting base URLs", () => { - const [parsed] = parse(config({ compat: { supportsStore: false, chatTemplateKwargs: { a: true } } }, [model("named", { - name: "Named model", reasoning: true, input: ["text", "image"], thinkingLevelMap: { high: "high", xhigh: null }, - compat: { maxTokensField: "max_tokens", chatTemplateKwargs: { b: false } }, - samplingParams: { temperature: 0.5 }, samplingParamsByThinkingLevel: { high: { top_p: 0.9 } }, - })])); - assert.equal(parsed!.models[0]!.baseUrl, "http://example.invalid/custom/v1"); - assert.equal(parsed!.models[0]!.provider, "gateway"); - assert.equal(parsed!.models[0]!.name, "Named model"); - assert.deepEqual(parsed!.models[0]!.input, ["text", "image"]); - assert.deepEqual(parsed!.models[0]!.compat, { supportsStore: false, maxTokensField: "max_tokens", chatTemplateKwargs: { a: true, b: false } }); - assert.deepEqual(parsed!.models[0]!.samplingParamsByThinkingLevel, { high: { top_p: 0.9 } }); - }); - it("accepts Pi line comments, trailing commas and BOM without corrupting strings", () => { - const text = `\uFEFF${JSON.stringify(config()).replace('"providers":', '// comment\n"providers":').replace(/}$/, ',}')}`; - assert.equal(parseCustomProviders(text, new Set(), customProviderApis)[0]!.models[0]!.baseUrl, "http://example.invalid/custom/v1"); - }); - it("registers multiple providers and mixed native APIs without fixed backend IDs", () => { - const value = config(); - const raw = { providers: { ...value.providers, other: { ...value.providers.gateway, models: [model("chat"), model("anthropic", { api: "anthropic-messages", baseUrl: "https://example.invalid/anthropic" })] } } }; - const models = createModels(); - for (const entry of parse(raw)) models.setProvider(createCustomProvider(entry, () => undefined)); - assert.equal(models.getProviders().length, 2); - assert.equal(models.getModel("other", "anthropic")!.api, "anthropic-messages"); - assert.equal(models.getModel("other", "anthropic")!.baseUrl, "https://example.invalid/anthropic"); - }); - it("requires explicit finite limits and nonnegative costs, instead of guessing", () => { - for (const field of ["contextWindow", "maxTokens", "cost"]) { - const m: Record = model(); delete m[field]; - assert.throws(() => parse(config({}, [m as ReturnType]))); - } - assert.throws(() => parse(config({}, [model("chat", { contextWindow: -1 })]))); - assert.throws(() => parse(config({}, [model("chat", { cost: { ...zero, output: -1 } })]))); - assert.throws(() => parseCustomProviders(JSON.stringify(config()).replace('"contextWindow":8192', '"contextWindow":1e999'), new Set(), customProviderApis)); - }); - it("rejects collisions, duplicate IDs, unsupported protocols, executable fields and invalid URLs", () => { - for (const id of ["openai", "chatgpt", "", "has:colon"]) assert.throws(() => parse({ providers: { [id]: config().providers.gateway } })); - assert.throws(() => parse(config({}, [model(), model()]))); - assert.throws(() => parse(config({ api: "unknown-api" }))); - assert.throws(() => parse(config({ oauth: "radius" }))); - assert.throws(() => parse(config({ modelOverrides: {} }))); - assert.throws(() => parse(config({ apiKey: "!touch /never-execute" }))); - assert.throws(() => parse(config({ headers: { "x-key": "!secret-command" } }))); - assert.throws(() => parse(config({}, [model("chat", { headers: { Authorization: "!command" } })]))); - for (const baseUrl of ["file:///tmp/model", "https://secret@example.invalid", "http://example.invalid/#fragment"]) assert.throws(() => parse(config({ baseUrl }))); - }); - it("matches Pi value interpolation and escapes, without recursive expansion or commands", () => { - const lookup = (key: string) => ({ KEY: "secret", DATA: "!never-executed" })[key as "KEY"]; - assert.equal(resolveCustomValue("Bearer ${KEY}/$KEY", lookup), "Bearer secret/secret"); - assert.equal(resolveCustomValue("$$KEY $!literal", lookup), "$KEY !literal"); - assert.equal(resolveCustomValue("$MISSING", lookup), undefined); - assert.equal(resolveCustomValue("$DATA", lookup), "!never-executed"); - }); -}); - -describe("custom providers in the existing application wiring", () => { - it("is empty before configuration and never creates a configuration file", () => { - assert.equal(fs.existsSync(CUSTOM_MODELS_FILE), false); - assert.equal(modelCatalog().customProvidersError, undefined); - assert.equal(fs.existsSync(CUSTOM_MODELS_FILE), false); - }); - it("projects into existing pickers, validation, context limits and Pi pricing", async () => { - save(config({ apiKey: "$GW_KEY", headers: { "x-gateway": "$GW_HEADER" } }, [model("chat", { cost: { ...zero, input: 2, output: 4 } })])); - const catalog = modelCatalog(); - assert.ok(catalog.providers.find((p) => p.id === "gateway")?.keyPresent); - assert.ok(catalog.models.some((m) => m.provider === "gateway" && m.id === "chat")); - assert.equal(JSON.stringify(catalog).includes("first-key"), false); - assert.equal(JSON.stringify(catalog).includes("first-header"), false); - assert.equal(validateModelsSettings({ strong: { model: { provider: "gateway", id: "chat" } } }).ok, true); - assert.equal(catalogContextLimits().get("gateway:chat"), 8192); - assert.ok(Math.abs(callCost({ inputTokens: 100, outputTokens: 50 }, "gateway:chat")! - 0.0004) < 1e-12); - assert.equal(providerKeyPresent("gateway"), true); - assert.equal((await piModels().getAuth("gateway"))!.auth.apiKey, "first-key"); - }); - it("uses current app key precedence and marks unresolved credentials/headers unavailable", () => { - process.env.LH_IGNORE_ENV_KEYS = "0"; - process.env.GW_KEY = "real-env-key"; - try { assert.equal(keyValue("GW_KEY"), "real-env-key"); } - finally { process.env.LH_IGNORE_ENV_KEYS = "1"; delete process.env.GW_KEY; } - save(config({ apiKey: "$MISSING_KEY" })); - assert.equal(providerKeyPresent("gateway"), false); - assert.equal(modelCatalog().models.some((m) => m.provider === "gateway"), false); - save(config({ headers: { "x-required": "$MISSING_HEADER" } })); - assert.equal(providerKeyPresent("gateway"), false); - save(config({ apiKey: undefined })); - assert.equal(providerKeyPresent("gateway"), false, "Pi-style keyless endpoints need an explicit dummy key"); - }); - it("revalidates every request of one engine while leaving an in-flight HTTP operation alone", async () => { - save(config({ apiKey: "$GW_KEY", headers: { "x-gateway": "$GW_HEADER" }, compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false, maxTokensField: "max_tokens" } })); - const engine = piEngineForSpec({ provider: "gateway", id: "chat" }); - const context = normalizeContext({ messages: [{ role: "user", content: "hello", timestamp: 1 }] }); - let started!: () => void, release!: (response: Response) => void; - const requestStarted = new Promise((resolve) => { started = resolve; }); - const firstResponse = new Promise((resolve) => { release = resolve; }); - const requests: { url: string; headers: Headers; body: Record }[] = []; - const original = globalThis.fetch; - globalThis.fetch = async (input, init) => { - const request = input instanceof Request ? input : new Request(input, init); - requests.push({ url: request.url, headers: request.headers, body: await request.json() as Record }); - if (requests.length === 1) { started(); return firstResponse; } - return sseText("héllo 🌍"); - }; - const pending = engine.streamFn(engine.model, context); - try { - await requestStarted; - authStore.setAuthVar("GW_KEY", "second-key"); - authStore.setAuthVar("GW_HEADER", "second-header"); - save(config({ baseUrl: "https://new.example.invalid/inference/v1", apiKey: "$GW_KEY", headers: { "x-gateway": "$GW_HEADER" }, compat: { supportsStore: false, maxTokensField: "max_tokens" } }, [model("chat", { maxTokens: 512 })])); - const second = await engine.streamFn(engine.model, context).result(); - assert.equal(second.stopReason, "stop"); - assert.deepEqual(second.content, [{ type: "text", text: "héllo 🌍" }]); - assert.equal(second.usage.input, 10); - assert.equal(second.usage.output, 2); - // Credential-only edits must also take effect on this same engine. - authStore.setAuthVar("GW_KEY", "third-key"); - assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "stop"); - // None of these cases may dispatch using the engine's old model/key. - save(config({ apiKey: "$MISSING_KEY" })); - assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error"); - save(config({}, [model("other-model")])); - assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error"); - fs.writeFileSync(CUSTOM_MODELS_FILE, "invalid configuration"); - assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error"); - fs.rmSync(CUSTOM_MODELS_FILE); - assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error"); - release(sseText("original request finished")); - const first = await pending.result(); - assert.equal(first.stopReason, "stop"); - assert.deepEqual(first.content, [{ type: "text", text: "original request finished" }]); - } finally { - release(sseText()); - await pending.result(); - globalThis.fetch = original; - } - assert.equal(requests.length, 3, "unavailable configurations must not cause additional transport calls"); - assert.equal(requests[0]!.url, "http://example.invalid/custom/v1/chat/completions"); - assert.equal(requests[0]!.headers.get("authorization"), "Bearer first-key"); - assert.equal(requests[0]!.headers.get("x-gateway"), "first-header"); - assert.equal(requests[1]!.url, "https://new.example.invalid/inference/v1/chat/completions"); - assert.equal(requests[1]!.headers.get("authorization"), "Bearer second-key"); - assert.equal(requests[1]!.headers.get("x-gateway"), "second-header"); - assert.equal(requests[2]!.headers.get("authorization"), "Bearer third-key"); - assert.equal(requests[0]!.body.model, "chat"); - assert.equal("store" in requests[0]!.body, false); - assert.equal(requests[0]!.body.max_tokens, 1024); - assert.equal(requests[1]!.body.max_tokens, 512, "current Pi model metadata, not the stale engine model, controls the request"); - }); - const thinking = (...levels: ThinkingLevel[]) => ({ - reasoning: true, - // Pi treats omitted levels through high as supported; mark every - // unselected level explicitly unsupported for these narrow fixtures. - thinkingLevelMap: { off: null, minimal: null, low: null, medium: null, high: null, xhigh: null, max: null, ...Object.fromEntries(levels.map((level) => [level, level])) }, - }); - const thinkingCases: { name: string; preference?: ThinkingLevel; initial: object; updated: object; before: ThinkingLevel; after: ThinkingLevel }[] = [ - { name: "recovers saved max after high-only metadata expands", preference: "max", initial: thinking("high"), updated: thinking("high", "max"), before: "high", after: "max" }, - { name: "recovers saved max after reasoning-off metadata expands", preference: "max", initial: { reasoning: false }, updated: thinking("high", "max"), before: "off", after: "max" }, - { name: "selects the existing medium-first default after reasoning becomes supported", initial: { reasoning: false }, updated: thinking("low", "medium", "high"), before: "off", after: "medium" }, - { name: "reselects the existing low-before-high default against updated metadata", initial: thinking("high"), updated: thinking("low", "high"), before: "high", after: "low" }, - { name: "clamps saved max when supported thinking contracts", preference: "max", initial: thinking("high", "max"), updated: thinking("high"), before: "max", after: "high" }, - { name: "omits reasoning when the model loses thinking support", preference: "max", initial: thinking("high", "max"), updated: { reasoning: false }, before: "max", after: "off" }, - ]; - for (const scenario of thinkingCases) it(scenario.name, async () => { - const definition = (metadata: object) => config({ compat: { supportsReasoningEffort: true, thinkingFormat: "openai" } }, [model("chat", metadata)]); - save(definition(scenario.initial)); - const engine = piEngineForSpec({ provider: "gateway", id: "chat", thinkingLevel: scenario.preference }); - assert.equal(engine.level, scenario.before); - const requests: Record[] = []; - const original = globalThis.fetch; - globalThis.fetch = async (input, init) => { - const request = input instanceof Request ? input : new Request(input, init); - requests.push(await request.json() as Record); - return sseText(); - }; - try { - const context = normalizeContext({ messages: [{ role: "user", content: "hello", timestamp: 1 }] }); - // A long-lived agent still forwards its initial thinking setting. - const options: SimpleStreamOptions = { reasoning: engine.level === "off" ? undefined : engine.level }; - assert.equal((await engine.streamFn(engine.model, context, options).result()).stopReason, "stop"); - save(definition(scenario.updated)); - assert.equal((await engine.streamFn(engine.model, context, options).result()).stopReason, "stop"); - } finally { globalThis.fetch = original; } - assert.equal(requests.length, 2); - for (const [index, level] of [scenario.before, scenario.after].entries()) { - assert.equal(requests[index]!.reasoning_effort, level === "off" ? undefined : level); - if (level === "off") assert.equal("reasoning_effort" in requests[index]!, false); - } - }); - it("disables custom routes on malformed edits without removing built-ins or saved choices", () => { - fs.writeFileSync(SETTINGS_FILE, JSON.stringify({ version: 1, models: { strong: { model: { provider: "gateway", id: "chat" }, thinkingLevel: "high" } } })); - const before = fs.readFileSync(SETTINGS_FILE, "utf8"); - fs.writeFileSync(CUSTOM_MODELS_FILE, '{ secret-token-do-not-log'); - const catalog = modelCatalog(); - assert.ok(catalog.customProvidersError); - assert.equal(catalog.customProvidersError!.includes("secret-token"), false); - assert.equal(piModels().getProvider("gateway"), undefined); - assert.ok(piModels().getProvider("openai")); - assert.deepEqual(catalog.slots.strong!.model, { provider: "gateway", id: "chat" }); - const stored = { strong: { model: { provider: "gateway", id: "chat" }, thinkingLevel: "high" as const } }; - const repaired = { strong: { ...stored.strong, thinkingLevel: "max" as const }, cheap: { model: { provider: "openai", id: piModels().getModels("openai")[0]!.id } } }; - assert.equal(validateModelsSettings(repaired, stored).ok, true, "an unavailable custom pin must not prevent independent slot repair"); - assert.equal(clampModelsLevels(repaired).strong!.thinkingLevel, "max", "preserve thinking while custom metadata is unavailable"); - assert.equal(validateModelsSettings({ cheap: stored.strong }, stored).ok, false, "unchanged identity preservation is same-slot only"); - assert.equal(fs.readFileSync(SETTINGS_FILE, "utf8"), before); - assert.throws(() => piEngineForSpec({ provider: "gateway", id: "chat" }), /catalog has no model/); - save(config()); - assert.equal(modelCatalog().customProvidersError, undefined); - assert.ok(piModels().getProvider("gateway")); - fs.rmSync(CUSTOM_MODELS_FILE); - assert.equal(modelCatalog().models.some((m) => m.provider === "gateway"), false); - assert.equal(modelCatalog().customProvidersError, undefined); - assert.equal(fs.readFileSync(SETTINGS_FILE, "utf8"), before); - }); - it("refreshes credentials without a file edit, preserving native header precedence and snapshots", async () => { - const values = new Map([["KEY", "one"], ["HEADER", "first"]]); - const source = new CustomProviderSource(() => ({ text: JSON.stringify(config({ apiKey: "$KEY", authHeader: true, headers: { "x-shared": "$HEADER" } }, [model("chat", { headers: { "x-shared": "model-${HEADER}" } })])) }), (name) => values.get(name)); - const models = createModels(); - source.sync(models); - const captured = createModels(); - captured.setProvider(models.getProvider("gateway")!); - values.set("KEY", "two"); values.set("HEADER", "second"); - source.sync(models); - const current = (await models.getAuth(models.getModel("gateway", "chat")!))!.auth; - const previous = (await captured.getAuth(captured.getModel("gateway", "chat")!))!.auth; - assert.equal(current.apiKey, "two"); - assert.equal(current.headers!["x-shared"], "model-second"); - assert.equal(current.headers!.Authorization, "Bearer two"); - assert.equal(previous.apiKey, "one"); - assert.equal(previous.headers!["x-shared"], "model-first"); - }); - it("rejects built-in, subscription and native CLI collisions without replacing their providers", () => { - for (const id of ["openai", "github-copilot", "chatgpt", "github-copilot-subscription", "xai-subscription", CLAUDE_CODE_PROVIDER]) { - const existing = piModels().getProvider("openai")!; - const nativeCli = piModels().getProvider(CLAUDE_CODE_PROVIDER)!; - const models = createModels(); models.setProvider(existing); models.setProvider(nativeCli); - const source = new CustomProviderSource(() => ({ text: JSON.stringify({ providers: { [id]: config().providers.gateway } }) }), () => undefined); - source.sync(models); - assert.ok(source.getError()); - assert.equal(models.getProvider("openai"), existing); - assert.equal(models.getProvider(CLAUDE_CODE_PROVIDER), nativeCli); - assert.equal(models.getProviders().length, 2); - } - }); - it("cannot bypass CLI opt-in or replace/remove the native provider across custom configuration reloads", (t) => { - const nativeCli = piModels().getProvider(CLAUDE_CODE_PROVIDER)!; - assert.ok(nativeCli); - const aliases = piModels().getModels(CLAUDE_CODE_PROVIDER); - const savedConfig = fs.existsSync(CUSTOM_MODELS_FILE) ? fs.readFileSync(CUSTOM_MODELS_FILE, "utf8") : undefined; - const savedSettings = fs.existsSync(SETTINGS_FILE) ? fs.readFileSync(SETTINGS_FILE, "utf8") : undefined; - t.after(() => { - if (savedConfig === undefined) fs.rmSync(CUSTOM_MODELS_FILE, { force: true }); else fs.writeFileSync(CUSTOM_MODELS_FILE, savedConfig); - if (savedSettings === undefined) fs.rmSync(SETTINGS_FILE, { force: true }); else fs.writeFileSync(SETTINGS_FILE, savedSettings); - piModels(); - }); - fs.writeFileSync(SETTINGS_FILE, JSON.stringify({ version: 1, claudeCode: { enabled: false } })); - const collision = { providers: { [CLAUDE_CODE_PROVIDER]: config().providers.gateway } }; - for (const [value, error] of [ - [collision, true], // Initial collision must never enter registered/ready. - [config(), false], // A normal custom route still works. - [collision, true], // Invalid reload retires custom routes, not native ones. - ["malformed configuration", true], - [undefined, false], // Removing the file must not delete the native CLI. - [config(), false], - [undefined, false], - ] as const) { - if (value === undefined) fs.rmSync(CUSTOM_MODELS_FILE, { force: true }); - else if (typeof value === "string") fs.writeFileSync(CUSTOM_MODELS_FILE, value); - else save(value); - const catalog = modelCatalog(); - assert.equal(Boolean(catalog.customProvidersError), error); - assert.equal(customProviderConfigured(CLAUDE_CODE_PROVIDER), undefined); - assert.equal(providerKeyPresent(CLAUDE_CODE_PROVIDER), false, "custom auth must never bypass disabled CLI admission"); - assert.equal(piModels().getProvider(CLAUDE_CODE_PROVIDER), nativeCli, "preserve the native provider identity, not a reconstructed substitute"); - assert.deepEqual(piModels().getModels(CLAUDE_CODE_PROVIDER), aliases); - assert.equal(catalog.models.some((m) => m.provider === CLAUDE_CODE_PROVIDER), false); - } - }); - it("fails closed across partial publication and recovers without leaking internal errors", () => { - const text = JSON.stringify({ providers: { ...config().providers, second: config().providers.gateway } }); - const source = new CustomProviderSource(() => ({ text }), () => undefined); - const models = createModels(); - const builtin = piModels().getProvider("openai")!; - models.setProvider(builtin); - const publish = models.setProvider.bind(models); - models.setProvider = (provider) => { - if (provider.id === "second") throw new Error("private-value-do-not-log"); - publish(provider); - }; - source.sync(models); - assert.equal(models.getProvider("gateway"), undefined); - assert.equal(models.getProvider("second"), undefined); - assert.equal(models.getProvider("openai"), builtin); - assert.equal(source.getError()!.includes("private-value"), false); - models.setProvider = publish; - source.sync(models); - assert.ok(models.getProvider("gateway")); - assert.ok(models.getProvider("second")); - assert.equal(source.getError(), undefined); - }); - it("retires old custom routes when server configuration bootstrap is reinstalled", () => { - save(config()); - assert.ok(piModels().getProvider("gateway")); - fs.rmSync(CUSTOM_MODELS_FILE); - installCustomProviderSource(keyValue); - assert.equal(piModels().getProvider("gateway"), undefined); - assert.ok(piModels().getProvider("openai")); - }); - it("treats unreadable config as unavailable and recovers without a restart", () => { - let unreadable = true; - const source = new CustomProviderSource(() => unreadable ? { error: "Unable to read models.json." } : { text: JSON.stringify(config()) }, () => undefined); - const models = createModels(); - source.sync(models); - assert.equal(source.getError(), "Unable to read models.json."); - unreadable = false; - source.sync(models); - assert.ok(models.getProvider("gateway")); - assert.equal(source.getError(), undefined); - }); -}); diff --git a/tests/helpers/custom-provider-auth.ts b/tests/helpers/custom-provider-auth.ts new file mode 100644 index 0000000..acdeafe --- /dev/null +++ b/tests/helpers/custom-provider-auth.ts @@ -0,0 +1,10 @@ +/** Synthetic authorized records, imported only after each test's isolated env. */ +import { randomBytes } from "node:crypto"; +import type { CustomProviderSettings } from "../../src/core/settings.ts"; +import { persistCustomAuth } from "../../src/server/custom-provider-auth.ts"; + +export function authorizeFixture(cp: CustomProviderSettings, fileKey?: string): CustomProviderSettings { + const id = randomBytes(16).toString("hex"); + persistCustomAuth({ version: 1, id, baseUrl: cp.baseUrl, authMode: cp.authMode ?? "keyless", ...(fileKey ? { fileKeyBaseUrl: cp.baseUrl } : {}) }, fileKey); + return { ...cp, credentialBindingId: id }; +} diff --git a/tests/helpers/custom-provider-crash.ts b/tests/helpers/custom-provider-crash.ts new file mode 100644 index 0000000..62f2e5a --- /dev/null +++ b/tests/helpers/custom-provider-crash.ts @@ -0,0 +1,56 @@ +/** Child-process fixture: owned profile only; every upstream fetch is synthetic. */ +import fs from "node:fs"; +import { AUTH_FILE, SETTINGS_FILE } from "../../config.ts"; +const [mode, action = "key", fault = "none"] = process.argv.slice(2); +const requests: { url: string; authorization: string | null }[] = []; +globalThis.fetch = async (url, init) => { + requests.push({ url: String(url), authorization: new Headers(init?.headers).get("authorization") }); + if (String(url).endsWith("/models")) return Response.json({ data: [{ id: "m" }] }); + return new Response('data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"ok"},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', { headers: { "content-type": "text/event-stream" } }); +}; +const rename = fs.renameSync; +const write = fs.writeFileSync; +const read = fs.readFileSync; +let authRenames = 0; +fs.renameSync = (...args: Parameters) => { + if (args[1] === AUTH_FILE) { + authRenames++; + if (fault === "auth-before-rename" || (fault === "double-io" && authRenames > 1)) throw new Error("fixture auth I/O failure"); + rename(...args); + if (fault === "after-auth") process.exit(77); + return; + } + return rename(...args); +}; +fs.writeFileSync = (...args: Parameters) => { + if (args[0] === SETTINGS_FILE && fault === "double-io") throw new Error("fixture settings I/O failure"); + if (args[0] === SETTINGS_FILE && fault === "settings-landed") { write(...args); throw new Error("fixture ambiguous settings completion"); } + return write(...args); +}; +fs.readFileSync = ((...args: Parameters) => { + if (args[0] === AUTH_FILE && fault === "unreadable") throw Object.assign(new Error("fixture unreadable auth"), { code: "EACCES" }); + return read(...args); +}) as typeof read; +await import("../../src/server/keys.ts"); +if (mode === "inspect") { + const { piModels, piEngineForSpec } = await import("../../src/agent/models.ts"); + const { normalizeContext } = await import("@earendil-works/pi-ai"); + const enabled = Boolean(piModels().getProvider("custom")); + let reason: string | undefined; + try { + const engine = piEngineForSpec({ provider: "custom", id: "m" }); + reason = (await engine.streamFn(engine.model, normalizeContext({ messages: [{ role: "user", content: "fixture", timestamp: 0 }] }), {}).result()).stopReason; + } catch { reason = "refused"; } + const { keyStatuses } = await import("../../src/server/keys.ts"); + console.log(JSON.stringify({ enabled, reason, requests, statusIds: keyStatuses().map(row => row.id), metadataInEnv: process.env.__learnharness_custom_provider_binding !== undefined })); +} else { + const route = await import("../../app/api/custom-provider/route.ts"); + const get = route.GET(new Request("http://localhost/api/custom-provider")); + const payload = action === "keyless" ? { baseUrl: "http://second.invalid/v1", authMode: "keyless" } + : action === "envMove" ? { baseUrl: "http://second.invalid/v1", authMode: "apiKey", reuseKey: true } + : action === "reuse" ? { baseUrl: "http://first.invalid/v1", authMode: "apiKey", reuseKey: true } + : { baseUrl: "http://second.invalid/v1", authMode: "apiKey", key: "fixture-new-key" }; + const request = new Request("http://localhost/api/custom-provider", { method: action === "disconnect" ? "DELETE" : "POST", headers: { origin: "http://localhost", "content-type": "application/json", "if-match": get.headers.get("etag")! }, body: JSON.stringify(payload) }); + const result = await route.POST(request); + console.log(JSON.stringify({ status: result.status, body: await result.json(), requests })); +} From 6158723563619bdeb344cddfe4c161f1cc4161d8 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Thu, 29 Oct 2026 04:59:07 +0200 Subject: [PATCH 85/87] fix: fully redact custom credentials in key inventory --- app/api/keys/route.ts | 3 ++- src/core/auth-keys.ts | 3 ++- src/server/keys.ts | 4 +++- tests/custom-provider-boundaries.test.ts | 26 ++++++++++++++++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/app/api/keys/route.ts b/app/api/keys/route.ts index 9446d47..90f5d0f 100644 --- a/app/api/keys/route.ts +++ b/app/api/keys/route.ts @@ -10,7 +10,8 @@ * tests/auth-provider-keys.test.ts) — the settings UI reads it optionally * with a graceful fallback. * - * Masked only — last 4 + length; PLAINTEXT NEVER LEAVES THE STORE THROUGH + * Masked only — last 4 + length for ordinary keys; custom keys are fully + * redacted at every length (no credential suffix). PLAINTEXT NEVER LEAVES THE STORE THROUGH * THIS API (owner decision). `source` tells the page whether the live * value comes from the environment (dev .env.local / launch env) or the * file, and `shadowedByEnv` explains why a saved key is not the live one diff --git a/src/core/auth-keys.ts b/src/core/auth-keys.ts index 492ff15..b92faf7 100644 --- a/src/core/auth-keys.ts +++ b/src/core/auth-keys.ts @@ -207,7 +207,8 @@ export interface AuthKeyStatus { info: AuthKeyInfo | null; /** A key is present when EITHER the environment or the file has it. */ present: boolean; - /** The masked tail + character count, e.g. "…ab12 (64 chars)". */ + /** Ordinary masked tail + count, e.g. "…ab12 (64 chars)"; custom keys + * use full redaction, with no credential characters at any length. */ masked: string | null; /** Where the live value comes from, given env-wins precedence. */ source: "env" | "file" | "none"; diff --git a/src/server/keys.ts b/src/server/keys.ts index 5bb2b6a..3e197f1 100644 --- a/src/server/keys.ts +++ b/src/server/keys.ts @@ -162,7 +162,9 @@ export function resolveKeyStatuses( id, info: AUTH_KEYS.find((k) => k.id === id) ?? null, present: live !== undefined, - masked: live !== undefined ? `${maskKey(live)} (${live.trim().length} chars)` : null, + // Custom keys may be one character: even a masked suffix can expose + // the entire credential. Preserve inventory, return no key characters. + masked: live !== undefined ? id === CUSTOM_PROVIDER.envVar ? "••••" : `${maskKey(live)} (${live.trim().length} chars)` : null, source: isRealEnv ? "env" : live !== undefined ? "file" : "none", shadowedByEnv: isRealEnv && fileValue !== undefined && envValue !== fileValue, }); diff --git a/tests/custom-provider-boundaries.test.ts b/tests/custom-provider-boundaries.test.ts index 2ea1006..7c86a88 100644 --- a/tests/custom-provider-boundaries.test.ts +++ b/tests/custom-provider-boundaries.test.ts @@ -52,6 +52,32 @@ test("new keys have no arbitrary minimum; the exact probed key is stored", async assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), "k"); const body = await get().text(); assert.ok(!body.includes("Bearer")); }); +test("actual GET /api/keys fully redacts every custom key length and preserves other providers", async () => { + const inventory = await import("../app/api/keys/route.ts"); + const rows = async () => (await (await inventory.GET()).json()).keys as import("../src/core/auth-keys.ts").AuthKeyStatus[]; + assert.equal(keys.saveKey("OPENAI_API_KEY", "ordinary-provider-key-abcd").ok, true); + const ordinary = (await rows()).find(row => row.id === "OPENAI_API_KEY")!; + assert.equal(ordinary.masked, "…abcd (26 chars)"); + try { + for (const value of ["1", "12", "123", "1234", "synthetic-custom-secret-longer-than-four"]) { + assert.equal((await mutate({ baseUrl: "http://new/v1", authMode: "apiKey", key: value })).status, 200); + assert.equal(keys.keyValue(CUSTOM_PROVIDER.envVar), value, "short keys still save unchanged"); + const statuses = await rows(); + const custom = statuses.find(row => row.id === CUSTOM_PROVIDER.envVar)!; + assert.equal(custom.masked, "••••"); assert.equal(custom.present, true); assert.equal(custom.source, "file"); + assert.ok(!JSON.stringify(custom).includes(value)); + assert.deepEqual(statuses.find(row => row.id === "OPENAI_API_KEY"), ordinary); + } + process.env.LH_IGNORE_ENV_KEYS = "0"; + process.env[CUSTOM_PROVIDER.envVar] = "765"; authEnvRecord().realEnv.add(CUSTOM_PROVIDER.envVar); + const custom = (await rows()).find(row => row.id === CUSTOM_PROVIDER.envVar)!; + assert.equal(custom.masked, "••••"); assert.equal(custom.source, "env"); assert.equal(custom.present, true); assert.equal(custom.shadowedByEnv, true); + assert.ok(!JSON.stringify(custom).includes("765")); + } finally { + authEnvRecord().realEnv.delete(CUSTOM_PROVIDER.envVar); delete process.env[CUSTOM_PROVIDER.envVar]; process.env.LH_IGNORE_ENV_KEYS = "1"; + keys.deleteKey("OPENAI_API_KEY"); + } +}); test("changing destination cannot silently reuse an existing credential", async () => { setFileKey("private-old"); const payload = { baseUrl: "http://new/v1", authMode: "apiKey" }; From ee66a4b38197948a04ecd54cacc4a9d0e0679ba8 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Thu, 29 Oct 2026 05:27:41 +0200 Subject: [PATCH 86/87] ui: polish custom endpoint authentication and model controls --- components/settings/keys-section.tsx | 42 +++++++++++------------ tests/custom-provider-ui.test.tsx | 51 ++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 21 deletions(-) create mode 100644 tests/custom-provider-ui.test.tsx diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 219c3c5..b64f07e 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -909,7 +909,7 @@ function CustomEndpointEditor({ {authMode === "apiKey" && keySource === "env" &&

Environment supplies the effective key. Change the environment to replace it.

} {authMode === "apiKey" && keyPresent && (changedTarget || keyReuseRequired) && }
- { @@ -917,12 +917,12 @@ function CustomEndpointEditor({ setKeyTouched(true); if (!e.target.value.trim()) setAuthMode("keyless"); }} - placeholder={authMode === "keyless" ? "Keyless — saved/environment keys are not used" : keyPresent ? "Leave blank to keep effective key" : "API key"} + placeholder={keyPresent ? "Leave blank to keep effective key" : "API key"} aria-label="Custom endpoint API key" data-testid="custom-key-input" - disabled={busy || pinsBusy || authMode === "keyless" || keySource === "env"} + disabled={busy || pinsBusy || keySource === "env"} className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" - /> + />}
{!setup && models.length > 0 && (
- - Budget pins do not resize backend context. Blank uses reported limits, then fallback (8192 context / 4096 output). Custom pricing is unknown. -
{models.map((m) => { const d = draftOf(m); @@ -995,38 +992,41 @@ function CustomEndpointEditor({
); })}
- {pinsError && (

{pinsError}

)} +
)} -

Reasoning support is a capability declaration. The effort picker’s Off may not disable a custom backend’s default thinking.

+
+ {!setup && models.length > 0 &&

Budget pins do not resize backend context. Blank uses reported limits, then fallback (8192 context / 4096 output). Custom pricing is unknown.

} +

Reasoning support is a capability declaration. The effort picker’s Off may not disable a custom backend’s default thinking.

+
{(error || pinsError) && } {error && (

diff --git a/tests/custom-provider-ui.test.tsx b/tests/custom-provider-ui.test.tsx new file mode 100644 index 0000000..98c2992 --- /dev/null +++ b/tests/custom-provider-ui.test.tsx @@ -0,0 +1,51 @@ +import React, { act } from "react"; +(globalThis as Record).React = React; +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { createRoot, type Root } from "react-dom/client"; +import { parseHTML } from "linkedom"; +import { KeysSection, CustomEndpointSetup } from "../components/settings/keys-section.tsx"; + +const dom = parseHTML(""); +Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, HTMLInputElement: dom.window.HTMLInputElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true }); +const originalFetch = globalThis.fetch; +let root: Root | undefined; +let host: HTMLElement | undefined; +async function mount(setup = false) { + globalThis.fetch = async (input, init) => { + assert.equal(init?.method ?? "GET", "GET", "polish interactions must not save credentials/settings"); + if (String(input).startsWith("/api/custom-provider")) return Response.json({ baseUrl: "http://fixture.invalid/v1", authMode: "keyless", keySource: "none", keyPresent: false, keyReuseRequired: false, models: [{ id: "fixture-model" }], overrides: {}, manualModels: [] }, { headers: { etag: '"fixture"' } }); + return Response.json({ keys: [], providers: [] }); + }; + host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host); + await act(async () => root!.render(setup ? assert.fail("unexpected save")} /> : )); + if (!setup) await click("custom-endpoint-row-btn"); +} +function element(id: string) { const node = document.querySelector(`[data-testid="${id}"]`); assert.ok(node, id); return node; } +async function click(id: string) { await act(async () => element(id).dispatchEvent(new dom.window.Event("click", { bubbles: true }))); } +async function selectMode(value: string) { + const select = element("custom-auth-mode"); + Object.defineProperty(select, "value", { value, configurable: true }); + await act(async () => select.dispatchEvent(new dom.window.Event("change", { bubbles: true }))); +} +afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; globalThis.fetch = originalFetch; }); +for (const setup of [false, true]) { + test(`${setup ? "Setup" : "Settings"} hides key input in Keyless and reveals it only in API key mode`, async () => { + await mount(setup); + assert.equal(document.querySelector('[data-testid="custom-key-input"]'), null); + await selectMode("apiKey"); assert.equal(element("custom-key-input").getAttribute("type"), "password"); + await selectMode("keyless"); assert.equal(document.querySelector('[data-testid="custom-key-input"]'), null); + assert.ok(element("custom-save")); + }); +} +test("model controls use approved effort labels, right-align Save limits, and group help below", async () => { + await mount(); + const select = element("custom-pin-reasoning-fixture-model"); + assert.equal(select.getAttribute("aria-label"), "fixture-model reasoning effort support"); + assert.deepEqual(Array.from(select.children).map(option => option.textContent), ["Reasoning effort: Use reported", "Reasoning effort: Supported", "Reasoning effort: Not supported"]); + assert.deepEqual(Array.from(select.children).map(option => option.getAttribute("value")), ["", "on", "off"], "capability values unchanged"); + const save = element("custom-pins-save"); assert.ok(save.classList.contains("ml-auto")); assert.equal(save.parentElement!.lastElementChild, save); + const help = element("custom-help"); assert.equal(element("custom-budget-help").parentElement, help); assert.equal(element("custom-reasoning-help").parentElement, help); + const children = Array.from(element("custom-endpoint-editor").children); assert.ok(children.indexOf(help) > children.indexOf(element("custom-pins"))); + assert.match(help.textContent ?? "", /do not resize backend context/); assert.match(help.textContent ?? "", /Off may not disable/); +}); From dc4c398d5167865b84b0ccb3ec809eb88bf8f65f Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Thu, 29 Oct 2026 06:26:20 +0200 Subject: [PATCH 87/87] release: prepare 0.4.0 --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6ed8114..426b69f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "learnharness", - "version": "0.3.9", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "learnharness", - "version": "0.3.9", + "version": "0.4.0", "license": "AGPL-3.0-only", "dependencies": { "@earendil-works/pi-agent-core": "1.4.0", @@ -40,8 +40,8 @@ "mdast-util-from-markdown": "2.0.3", "mdast-util-math": "3.0.0", "media-chrome": "^4.19.2", - "micromark-extension-math": "3.1.0", "mermaid": "^11.17.0", + "micromark-extension-math": "3.1.0", "motion": "^13.1.1", "nanoid": "^6.0.1", "next": "^16.4.3", diff --git a/package.json b/package.json index f22b7e3..75e7953 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "learnharness", - "version": "0.3.9", + "version": "0.4.0", "license": "AGPL-3.0-only", "author": { "name": "Amos Blomqvist",