diff --git a/host/js/p4-test-bundle.js b/host/js/p4-test-bundle.js new file mode 100644 index 0000000..f2beee1 --- /dev/null +++ b/host/js/p4-test-bundle.js @@ -0,0 +1,1730 @@ +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __commonJS = (cb, mod) => function __require() { + try { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; + } catch (e) { + throw mod = 0, e; + } +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); + +// host/js/node_modules/partial-json/dist/options.js +var require_options = __commonJS({ + "host/js/node_modules/partial-json/dist/options.js"(exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.Allow = exports.ALL = exports.COLLECTION = exports.ATOM = exports.SPECIAL = exports.INF = exports._INFINITY = exports.INFINITY = exports.NAN = exports.BOOL = exports.NULL = exports.OBJ = exports.ARR = exports.NUM = exports.STR = void 0; + exports.STR = 1; + exports.NUM = 2; + exports.ARR = 4; + exports.OBJ = 8; + exports.NULL = 16; + exports.BOOL = 32; + exports.NAN = 64; + exports.INFINITY = 128; + exports._INFINITY = 256; + exports.INF = exports.INFINITY | exports._INFINITY; + exports.SPECIAL = exports.NULL | exports.BOOL | exports.INF | exports.NAN; + exports.ATOM = exports.STR | exports.NUM | exports.SPECIAL; + exports.COLLECTION = exports.ARR | exports.OBJ; + exports.ALL = exports.ATOM | exports.COLLECTION; + exports.Allow = { STR: exports.STR, NUM: exports.NUM, ARR: exports.ARR, OBJ: exports.OBJ, NULL: exports.NULL, BOOL: exports.BOOL, NAN: exports.NAN, INFINITY: exports.INFINITY, _INFINITY: exports._INFINITY, INF: exports.INF, SPECIAL: exports.SPECIAL, ATOM: exports.ATOM, COLLECTION: exports.COLLECTION, ALL: exports.ALL }; + exports.default = exports.Allow; + } +}); + +// host/js/node_modules/partial-json/dist/index.js +var require_dist = __commonJS({ + "host/js/node_modules/partial-json/dist/index.js"(exports) { + "use strict"; + var __createBinding = exports && exports.__createBinding || (Object.create ? (function(o, m, k, k2) { + if (k2 === void 0) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { + return m[k]; + } }; + } + Object.defineProperty(o, k2, desc); + }) : (function(o, m, k, k2) { + if (k2 === void 0) k2 = k; + o[k2] = m[k]; + })); + var __exportStar = exports && exports.__exportStar || function(m, exports2) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports2, p)) __createBinding(exports2, m, p); + }; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.Allow = exports.MalformedJSON = exports.PartialJSON = exports.parseJSON = exports.parse = void 0; + var options_1 = require_options(); + Object.defineProperty(exports, "Allow", { enumerable: true, get: function() { + return options_1.Allow; + } }); + __exportStar(require_options(), exports); + var PartialJSON = class extends Error { + }; + exports.PartialJSON = PartialJSON; + var MalformedJSON = class extends Error { + }; + exports.MalformedJSON = MalformedJSON; + function parseJSON(jsonString, allowPartial = options_1.Allow.ALL) { + if (typeof jsonString !== "string") { + throw new TypeError(`expecting str, got ${typeof jsonString}`); + } + if (!jsonString.trim()) { + throw new Error(`${jsonString} is empty`); + } + return _parseJSON(jsonString.trim(), allowPartial); + } + exports.parseJSON = parseJSON; + var _parseJSON = (jsonString, allow) => { + const length = jsonString.length; + let index = 0; + const markPartialJSON = (msg) => { + throw new PartialJSON(`${msg} at position ${index}`); + }; + const throwMalformedError = (msg) => { + throw new MalformedJSON(`${msg} at position ${index}`); + }; + const parseAny = () => { + skipBlank(); + if (index >= length) + markPartialJSON("Unexpected end of input"); + if (jsonString[index] === '"') + return parseStr(); + if (jsonString[index] === "{") + return parseObj(); + if (jsonString[index] === "[") + return parseArr(); + if (jsonString.substring(index, index + 4) === "null" || options_1.Allow.NULL & allow && length - index < 4 && "null".startsWith(jsonString.substring(index))) { + index += 4; + return null; + } + if (jsonString.substring(index, index + 4) === "true" || options_1.Allow.BOOL & allow && length - index < 4 && "true".startsWith(jsonString.substring(index))) { + index += 4; + return true; + } + if (jsonString.substring(index, index + 5) === "false" || options_1.Allow.BOOL & allow && length - index < 5 && "false".startsWith(jsonString.substring(index))) { + index += 5; + return false; + } + if (jsonString.substring(index, index + 8) === "Infinity" || options_1.Allow.INFINITY & allow && length - index < 8 && "Infinity".startsWith(jsonString.substring(index))) { + index += 8; + return Infinity; + } + if (jsonString.substring(index, index + 9) === "-Infinity" || options_1.Allow._INFINITY & allow && 1 < length - index && length - index < 9 && "-Infinity".startsWith(jsonString.substring(index))) { + index += 9; + return -Infinity; + } + if (jsonString.substring(index, index + 3) === "NaN" || options_1.Allow.NAN & allow && length - index < 3 && "NaN".startsWith(jsonString.substring(index))) { + index += 3; + return NaN; + } + return parseNum(); + }; + const parseStr = () => { + const start = index; + let escape = false; + index++; + while (index < length && (jsonString[index] !== '"' || escape && jsonString[index - 1] === "\\")) { + escape = jsonString[index] === "\\" ? !escape : false; + index++; + } + if (jsonString.charAt(index) == '"') { + try { + return JSON.parse(jsonString.substring(start, ++index - Number(escape))); + } catch (e) { + throwMalformedError(String(e)); + } + } else if (options_1.Allow.STR & allow) { + try { + return JSON.parse(jsonString.substring(start, index - Number(escape)) + '"'); + } catch (e) { + return JSON.parse(jsonString.substring(start, jsonString.lastIndexOf("\\")) + '"'); + } + } + markPartialJSON("Unterminated string literal"); + }; + const parseObj = () => { + index++; + skipBlank(); + const obj = {}; + try { + while (jsonString[index] !== "}") { + skipBlank(); + if (index >= length && options_1.Allow.OBJ & allow) + return obj; + const key = parseStr(); + skipBlank(); + index++; + try { + const value = parseAny(); + obj[key] = value; + } catch (e) { + if (options_1.Allow.OBJ & allow) + return obj; + else + throw e; + } + skipBlank(); + if (jsonString[index] === ",") + index++; + } + } catch (e) { + if (options_1.Allow.OBJ & allow) + return obj; + else + markPartialJSON("Expected '}' at end of object"); + } + index++; + return obj; + }; + const parseArr = () => { + index++; + const arr = []; + try { + while (jsonString[index] !== "]") { + arr.push(parseAny()); + skipBlank(); + if (jsonString[index] === ",") { + index++; + } + } + } catch (e) { + if (options_1.Allow.ARR & allow) { + return arr; + } + markPartialJSON("Expected ']' at end of array"); + } + index++; + return arr; + }; + const parseNum = () => { + if (index === 0) { + if (jsonString === "-") + throwMalformedError("Not sure what '-' is"); + try { + return JSON.parse(jsonString); + } catch (e) { + if (options_1.Allow.NUM & allow) + try { + return JSON.parse(jsonString.substring(0, jsonString.lastIndexOf("e"))); + } catch (e2) { + } + throwMalformedError(String(e)); + } + } + const start = index; + if (jsonString[index] === "-") + index++; + while (jsonString[index] && ",]}".indexOf(jsonString[index]) === -1) + index++; + if (index == length && !(options_1.Allow.NUM & allow)) + markPartialJSON("Unterminated number literal"); + try { + return JSON.parse(jsonString.substring(start, index)); + } catch (e) { + if (jsonString.substring(start, index) === "-") + markPartialJSON("Not sure what '-' is"); + try { + return JSON.parse(jsonString.substring(start, jsonString.lastIndexOf("e"))); + } catch (e2) { + throwMalformedError(String(e2)); + } + } + }; + const skipBlank = () => { + while (index < length && " \n\r ".includes(jsonString[index])) { + index++; + } + }; + return parseAny(); + }; + var parse = parseJSON; + exports.parse = parse; + } +}); + +// host/js/node_modules/@earendil-works/pi-ai/dist/utils/event-stream.js +var FifoQueue = class { + incoming = []; + outgoing = []; + get length() { + return this.incoming.length + this.outgoing.length; + } + enqueue(value) { + this.incoming.push(value); + } + dequeue() { + if (this.outgoing.length === 0) { + while (this.incoming.length > 0) { + this.outgoing.push(this.incoming.pop()); + } + } + return this.outgoing.pop(); + } +}; +var EventStream = class { + queue = new FifoQueue(); + waiting = new FifoQueue(); + done = false; + finalResultPromise; + resolveFinalResult; + isComplete; + extractResult; + constructor(isComplete, extractResult) { + this.isComplete = isComplete; + this.extractResult = extractResult; + this.finalResultPromise = new Promise((resolve) => { + this.resolveFinalResult = resolve; + }); + } + push(event) { + if (this.done) + return; + if (this.isComplete(event)) { + this.done = true; + this.resolveFinalResult(this.extractResult(event)); + } + const waiter = this.waiting.dequeue(); + if (waiter) { + waiter({ value: event, done: false }); + } else { + this.queue.enqueue(event); + } + } + end(result) { + this.done = true; + if (result !== void 0) { + this.resolveFinalResult(result); + } + while (this.waiting.length > 0) { + const waiter = this.waiting.dequeue(); + waiter({ value: void 0, done: true }); + } + } + async *[Symbol.asyncIterator]() { + while (true) { + if (this.queue.length > 0) { + yield this.queue.dequeue(); + } else if (this.done) { + return; + } else { + const result = await new Promise((resolve) => this.waiting.enqueue(resolve)); + if (result.done) + return; + yield result.value; + } + } + } + result() { + return this.finalResultPromise; + } +}; +var AssistantMessageEventStream = class extends EventStream { + #startedAt = Date.now(); + #startedAtMonotonic = performance.now(); + constructor() { + super((event) => event.type === "done" || event.type === "error", (event) => { + if (event.type === "done") { + return event.message; + } else if (event.type === "error") { + return event.error; + } + throw new Error("Unexpected event type for final result"); + }); + } + push(event) { + if (event.type === "done") + this.#time(event.message); + else if (event.type === "error") + this.#time(event.error); + super.push(event); + } + end(result) { + if (result !== void 0) + this.#time(result); + super.end(result); + } + #time(message) { + if (this.done || message.durationMs !== void 0 || message.timestamp < this.#startedAt) + return; + message.durationMs = Math.max(0, Math.round(performance.now() - this.#startedAtMonotonic)); + } +}; +function createAssistantMessageEventStream() { + return new AssistantMessageEventStream(); +} + +// host/js/node_modules/@earendil-works/pi-ai/dist/api/lazy.js +function createSetupErrorMessage(model, error, timestamp) { + 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: "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp + }; +} +function hasResult(source) { + return typeof source.result === "function"; +} +async function forwardStream(target, source, observer, observeResult) { + let result; + for await (const event of source) { + target.push(event); + observer?.onEvent?.(event); + if (event.type === "done") + result = event.message; + else if (event.type === "error") + result = event.error; + } + if (hasResult(source)) + result = await source.result(); + target.end(result); + observeResult(result); +} +function lazyStream(model, setup, observer) { + const startedAt = Date.now(); + const outer = new AssistantMessageEventStream(); + let observedResult = false; + const observeResult = (result) => { + if (observedResult) + return; + observedResult = true; + observer?.onResult?.(result); + }; + setup().then((inner) => forwardStream(outer, inner, observer, observeResult)).catch((error) => { + const message = createSetupErrorMessage(model, error, startedAt); + const event = { type: "error", reason: "error", error: message }; + outer.push(event); + observer?.onEvent?.(event); + outer.end(message); + observeResult(message); + }); + return outer; +} + +// host/js/node_modules/@earendil-works/pi-ai/dist/utils/diagnostics.js +function formatThrownValue(value) { + if (value instanceof Error) + return value.message || value.name; + if (typeof value === "string") + return value; + return String(value); +} + +// host/js/node_modules/@earendil-works/pi-ai/dist/utils/models-error.js +var ModelsError = class extends Error { + code; + constructor(code, message, options) { + super(withCauseDetail(message, options?.cause), options); + this.name = "ModelsError"; + this.code = code; + } +}; +function withCauseDetail(message, cause) { + if (cause === void 0 || cause === null) + return message; + const detail = formatThrownValue(cause).trim(); + if (!detail || message.includes(detail)) + return message; + return `${message}: ${detail}`; +} + +// host/js/node_modules/@earendil-works/pi-ai/dist/auth/resolve.js +var DEFAULT_OAUTH_MINIMUM_VALIDITY_MS = 5 * 60 * 1e3; + +// host/js/node_modules/@earendil-works/pi-ai/dist/utils/text.js +function contentText(content, separator = "\n") { + if (typeof content === "string") + return content; + return content.filter((block) => block.type === "text").map((block) => block.text).join(separator); +} +function getSystemMessageText(message) { + const parts = [contentText(message.content)]; + for (const text of Object.values(message.sections ?? {})) { + if (text !== null) + parts.push(text); + } + return parts.filter((part) => part.length > 0).join("\n\n"); +} + +// host/js/node_modules/@earendil-works/pi-ai/dist/utils/model-operations.js +function getModelType(model) { + return model.type ?? "chat"; +} +function isModelType(model, type) { + return getModelType(model) === type; +} +function imageErrorResult(model, error, aborted = false) { + return { + api: model.api, + provider: model.provider, + model: model.id, + output: [], + stopReason: aborted ? "aborted" : "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now() + }; +} +function classifierErrorResult(model, error, aborted = false) { + return { + api: model.api, + provider: model.provider, + model: model.id, + answers: {}, + stopReason: aborted ? "aborted" : "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now() + }; +} + +// host/js/node_modules/@earendil-works/pi-ai/dist/models.js +var KNOWN_MODEL_TYPES = { chat: true, image: true, classifier: true }; +function hasKnownModelType(model) { + return Object.hasOwn(KNOWN_MODEL_TYPES, getModelType(model)); +} +function createProvider(input) { + const single = input.api && typeof input.api.stream === "function" ? input.api : void 0; + const byApi = single || !input.api ? void 0 : input.api; + const images = input.images; + const classifiers = input.classifiers; + const streams = single ? [single] : Object.values(byApi ?? {}).filter((entry) => entry !== void 0); + const imageImplementations = Object.values(images ?? {}).filter((entry) => entry !== void 0); + const classifierImplementations = Object.values(classifiers ?? {}).filter((entry) => entry !== void 0); + if (streams.length === 0 && imageImplementations.length === 0 && classifierImplementations.length === 0) { + throw new Error(`Provider ${input.id}: at least one of "api", "images", or "classifiers" is required.`); + } + const baselineModels = input.models; + let dynamicModels = []; + const fetchModels = input.fetchModels; + const currentModels = () => { + const merged = [...baselineModels]; + for (const model of dynamicModels) { + const index = merged.findIndex((entry) => getModelType(entry) === getModelType(model) && entry.id === model.id); + if (index >= 0) + merged[index] = model; + else + merged.push(model); + } + return merged; + }; + const apiFor = (model) => single ?? byApi?.[model.api]; + const dispatch = (model, run) => { + const streams2 = apiFor(model); + if (!streams2) { + return lazyStream(model, async () => { + throw new ModelsError("stream", `Provider ${input.id} has no API implementation for "${model.api}"`); + }); + } + return run(streams2); + }; + const provider = { + id: input.id, + name: input.name ?? input.id, + baseUrl: input.baseUrl, + headers: input.headers, + auth: input.auth, + getModels: () => currentModels().filter((model) => isModelType(model, "chat")), + getAllModels: currentModels, + refreshModels: fetchModels ? async (context) => { + if (context.stored) { + const restored = context.stored.models.filter((model) => model.provider === input.id).map((model) => model); + if (!await context.publish({ + update: () => { + dynamicModels = restored; + } + })) { + return; + } + } + if (!context.allowNetwork || context.signal.aborted) + return; + const fetched = await fetchModels(context); + if (context.signal.aborted) + return; + const refreshed = fetched.filter(hasKnownModelType); + await context.publish({ + persist: { models: refreshed, checkedAt: Date.now() }, + update: () => { + dynamicModels = refreshed; + } + }); + } : void 0, + filterModels: input.filterModels, + filterAllModels: input.filterAllModels, + stream: (model, context, options) => dispatch(model, (streams2) => streams2.stream(model, context, options)), + streamSimple: (model, context, options) => dispatch(model, (streams2) => streams2.streamSimple(model, context, options)) + }; + if (streams.some((entry) => entry.fetchDeferred !== void 0)) { + provider.fetchDeferred = (model, handle, options) => lazyStream(model, async () => { + const implementation = apiFor(model); + if (!implementation?.fetchDeferred) { + throw new ModelsError("provider", `Provider ${input.id} does not support deferred responses for "${model.api}"`); + } + return implementation.fetchDeferred(model, handle, options); + }); + } + if (streams.some((entry) => entry.cancelDeferred !== void 0)) { + provider.cancelDeferred = async (model, handle, options) => { + const implementation = apiFor(model); + if (!implementation?.cancelDeferred) { + throw new ModelsError("provider", `Provider ${input.id} cannot cancel deferred responses for "${model.api}"`); + } + await implementation.cancelDeferred(model, handle, options); + }; + } + if (images && imageImplementations.length > 0) { + provider.generateImages = async (model, context, options) => { + const implementation = images[model.api]; + if (!implementation) { + return imageErrorResult(model, new ModelsError("provider", `Provider ${input.id} has no image generation implementation for "${model.api}"`)); + } + return implementation.generateImages(model, context, options); + }; + } + if (classifiers && classifierImplementations.length > 0) { + provider.classify = async (model, context, options) => { + const implementation = classifiers[model.api]; + if (!implementation) { + return classifierErrorResult(model, new ModelsError("provider", `Provider ${input.id} has no classifier implementation for "${model.api}"`)); + } + return implementation.classify(model, context, options); + }; + } + return provider; +} + +// host/js/node_modules/@earendil-works/pi-ai/dist/utils/json-parse.js +var import_partial_json = __toESM(require_dist(), 1); +var VALID_JSON_ESCAPES = /* @__PURE__ */ new Set(['"', "\\", "/", "b", "f", "n", "r", "t"]); +function isControlCharacter(char) { + const codePoint = char.codePointAt(0); + return codePoint !== void 0 && codePoint >= 0 && codePoint <= 31; +} +function escapeControlCharacter(char) { + switch (char) { + case "\b": + return "\\b"; + case "\f": + return "\\f"; + case "\n": + return "\\n"; + case "\r": + return "\\r"; + case " ": + return "\\t"; + default: + return `\\u${char.codePointAt(0)?.toString(16).padStart(4, "0") ?? "0000"}`; + } +} +function repairJson(json) { + let repaired = ""; + let inString = false; + for (let index = 0; index < json.length; index++) { + const char = json[index]; + if (!inString) { + repaired += char; + if (char === '"') { + inString = true; + } + continue; + } + if (char === '"') { + repaired += char; + inString = false; + continue; + } + if (char === "\\") { + const nextChar = json[index + 1]; + if (nextChar === void 0) { + repaired += "\\\\"; + continue; + } + if (nextChar === "u") { + const unicodeDigits = json.slice(index + 2, index + 6); + if (/^[0-9a-fA-F]{4}$/.test(unicodeDigits)) { + repaired += `\\u${unicodeDigits}`; + index += 5; + continue; + } + } + if (VALID_JSON_ESCAPES.has(nextChar)) { + repaired += `\\${nextChar}`; + index += 1; + continue; + } + repaired += "\\\\"; + continue; + } + repaired += isControlCharacter(char) ? escapeControlCharacter(char) : char; + } + return repaired; +} +function parseJsonWithRepair(json) { + try { + return JSON.parse(json); + } catch (error) { + const repairedJson = repairJson(json); + if (repairedJson !== json) { + return JSON.parse(repairedJson); + } + throw error; + } +} +function parseToolCallArguments(json) { + if (!json?.trim()) + return { arguments: {} }; + try { + const parsed = parseJsonWithRepair(json); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("Tool arguments must be a JSON object"); + } + return { arguments: parsed }; + } catch (error) { + return { + arguments: {}, + argumentsError: `${error instanceof Error ? error.message : String(error)} + +Received arguments: +${json}` + }; + } +} + +// host/js/node_modules/@earendil-works/pi-ai/dist/utils/streaming-json.js +var MAX_REPORTED_DEPTH = 32; +var ROOT_SLOT = { kind: "root" }; +var CREDIT_PER_APPEND = 256; +var CREDIT_PER_CHARACTER = 8; +var MAX_PENDING_SCALAR_LENGTH = 64; +var NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/; +var NUMBER_WITHOUT_EXPONENT_DIGITS = /^(-?(?:0|[1-9]\d*)(?:\.\d+)?)[eE][+-]?$/; +var LITERALS = [ + ["true", true], + ["false", false], + ["null", null] +]; +var QUOTE = 34; +var BACKSLASH = 92; +var StreamingJsonParser = class { + #root = void 0; + #done = false; + #stack = []; + #string = void 0; + #scalar = ""; + #scalarSlot = void 0; + /** Sum of `size + 1` over the open path: what a fresh snapshot copies. */ + #pathCost = 0; + #credit = 0; + #dirty = false; + #snapshot = void 0; + /** Changes of the piece being read. */ + #changes = []; + /** + * Read the next piece of text; returns the changes it made, in order. Costs time linear in `text.length`. + */ + append(text) { + this.#credit += CREDIT_PER_APPEND + CREDIT_PER_CHARACTER * text.length; + this.#changes = []; + if (this.#string !== void 0) + this.#string.change = void 0; + let index = 0; + while (index < text.length && !this.#done) { + const open = this.#string; + if (open !== void 0) + index = this.#readString(open, text, index); + else if (this.#scalar !== "") + index = this.#readScalar(text, index); + else + this.#structural(text[index++]); + } + return this.#changes; + } + /** + * The value of the text read so far: `undefined` before a value starts, otherwise a plain JSON value that never + * changes afterwards. Returns the previous snapshot while the text read since did not change the value, or while a + * fresh copy of the open path is not yet paid for (see the module comment). + */ + snapshot() { + if (!this.#dirty) + return this.#snapshot; + const pending = this.#pendingScalar(); + const cost = this.#pathCost + (pending === void 0 ? 0 : 1); + if (cost > this.#credit) + return this.#snapshot; + this.#credit -= cost; + this.#dirty = false; + this.#snapshot = this.#copyOpenPath(pending); + return this.#snapshot; + } + /** Read string characters from `start` until the string ends or the text does; returns the next index. */ + #readString(open, text, start) { + let runStart = start; + for (let index = start; index < text.length; index++) { + const code = text.charCodeAt(index); + if (open.escape !== "") { + if (this.#continueEscape(open, text[index])) { + runStart = index + 1; + continue; + } + runStart = index; + } + if (code === QUOTE) { + if (index > runStart) + this.#appendString(open, text.slice(runStart, index)); + this.#endString(open); + return index + 1; + } + if (code === BACKSLASH) { + if (index > runStart) + this.#appendString(open, text.slice(runStart, index)); + open.escape = "\\"; + runStart = index + 1; + } + } + if (text.length > runStart) + this.#appendString(open, text.slice(runStart)); + return text.length; + } + /** Read number or literal characters from `start` up to the next delimiter; returns the delimiter's index. */ + #readScalar(text, start) { + let end = start; + while (end < text.length && !isDelimiter(text[end])) + end++; + if (end > start) { + this.#scalar += text.slice(start, end); + if (this.#scalarSlot !== void 0) + this.#dirty = true; + } + if (end < text.length) + this.#endScalar(); + return end; + } + /** Handle one character outside strings. */ + #structural(char) { + switch (char) { + case " ": + case " ": + case "\n": + case "\r": + return; + case '"': + this.#startString(); + return; + case "{": + case "[": + this.#openContainer(char === "{" ? {} : []); + return; + case "}": + case "]": + this.#closeContainer(); + return; + case ":": { + const frame = this.#stack.at(-1); + if (frame !== void 0 && !Array.isArray(frame.container) && frame.phase === "colon") + frame.phase = "value"; + return; + } + case ",": { + const frame = this.#stack.at(-1); + if (frame !== void 0) + frame.phase = Array.isArray(frame.container) ? "value" : "key"; + return; + } + default: + this.#scalar = char; + this.#scalarSlot = this.#valueSlot(); + if (this.#scalarSlot !== void 0) + this.#dirty = true; + } + } + /** Where a value starting now goes, or `undefined` when this position takes no value. */ + #valueSlot() { + const frame = this.#stack.at(-1); + if (frame === void 0) + return this.#root === void 0 ? ROOT_SLOT : void 0; + if (Array.isArray(frame.container)) + return { kind: "element", index: frame.container.length }; + if ((frame.phase === "value" || frame.phase === "colon") && frame.key !== void 0) { + return { kind: "member", key: frame.key }; + } + return void 0; + } + #startString() { + const frame = this.#stack.at(-1); + if (frame !== void 0 && !Array.isArray(frame.container) && (frame.phase === "key" || frame.phase === "comma")) { + this.#string = { target: "key", slot: void 0, text: "", escape: "", path: void 0, change: void 0 }; + return; + } + const slot = this.#valueSlot(); + const path = this.#reportedPath(slot); + this.#string = { target: "value", slot, text: "", escape: "", path, change: void 0 }; + this.#place(slot, ""); + if (path !== void 0) + this.#string.change = this.#report({ kind: "set", path, value: "" }); + } + /** The path of a value at `slot` in the innermost open container, when its changes are reported. */ + #reportedPath(slot) { + if (slot === void 0) + return void 0; + if (slot.kind === "root") + return []; + const frame = this.#stack.at(-1); + if (!frame.reportsInside) + return void 0; + return [...frame.path, slot.kind === "member" ? slot.key : slot.index]; + } + #report(change) { + this.#changes.push(change); + return change; + } + /** Continue an escape with `char`; returns false when `char` is not part of it and must be read normally. */ + #continueEscape(open, char) { + if (open.escape === "\\") { + const decoded = SIMPLE_ESCAPES[char]; + if (decoded !== void 0) { + open.escape = ""; + this.#appendString(open, decoded); + return true; + } + if (char === "u") { + open.escape = "\\u"; + return true; + } + open.escape = ""; + this.#appendString(open, "\\"); + return false; + } + if (isHexDigit(char)) { + open.escape += char; + if (open.escape.length < 6) + return true; + const decoded = String.fromCharCode(Number.parseInt(open.escape.slice(2), 16)); + open.escape = ""; + this.#appendString(open, decoded); + return true; + } + const literal = open.escape; + open.escape = ""; + this.#appendString(open, literal); + return false; + } + #appendString(open, text) { + open.text += text; + if (open.target !== "value") + return; + this.#place(open.slot, open.text); + if (open.path === void 0) + return; + const change = open.change; + if (change !== void 0 && this.#changes.at(-1) === change) { + if (change.kind === "set") + change.value = change.value + text; + else + change.text += text; + return; + } + open.change = this.#report({ kind: "append", path: open.path, text }); + } + #endString(open) { + this.#string = void 0; + const frame = this.#stack.at(-1); + if (open.target === "key") { + if (frame === void 0) + return; + frame.key = open.text; + frame.phase = "colon"; + return; + } + this.#valueEnded(frame); + } + #endScalar() { + const token = this.#scalar; + const slot = this.#scalarSlot; + this.#scalar = ""; + this.#scalarSlot = void 0; + const value = scalarValue(token); + if (value !== void 0) { + this.#place(slot, value); + const path = this.#reportedPath(slot); + if (path !== void 0) + this.#report({ kind: "set", path, value }); + } + this.#valueEnded(this.#stack.at(-1)); + } + /** The value of the unterminated scalar at the end of the text, if it shows. */ + #pendingScalar() { + const slot = this.#scalarSlot; + const token = this.#scalar; + if (slot === void 0 || token === "" || token.length > MAX_PENDING_SCALAR_LENGTH) + return void 0; + const value = pendingScalarValue(token); + return value === void 0 ? void 0 : { slot, value }; + } + #openContainer(container) { + const slot = this.#valueSlot(); + const path = this.#reportedPath(slot); + this.#place(slot, container); + this.#stack.push({ + container, + slot, + path, + reportsInside: path !== void 0 && path.length < MAX_REPORTED_DEPTH, + size: 0, + key: void 0, + phase: Array.isArray(container) ? "value" : "key" + }); + this.#pathCost += 1; + this.#dirty = true; + if (path !== void 0) + this.#report({ kind: "set", path, value: Array.isArray(container) ? [] : {} }); + } + #closeContainer() { + const frame = this.#stack.pop(); + if (frame === void 0) + return; + this.#pathCost -= frame.size + 1; + this.#dirty = true; + if (frame.path !== void 0 && !frame.reportsInside) { + this.#report({ kind: "set", path: frame.path, value: frame.container }); + } + this.#valueEnded(this.#stack.at(-1)); + } + /** A value in `frame`, or the root value when there is no frame, has ended. */ + #valueEnded(frame) { + if (frame === void 0) { + if (this.#root !== void 0) + this.#done = true; + return; + } + frame.phase = "comma"; + } + /** Set the value at `slot` in the open path; nothing for a value in an invalid position. */ + #place(slot, value) { + if (slot === void 0) + return; + this.#dirty = true; + if (slot.kind === "root") { + this.#root = value; + return; + } + const frame = this.#stack.at(-1); + if (slot.kind === "element") { + const elements = frame.container; + if (slot.index === elements.length) { + frame.size++; + this.#pathCost++; + } + elements[slot.index] = value; + return; + } + const members = frame.container; + if (!Object.hasOwn(members, slot.key)) { + frame.size++; + this.#pathCost++; + } + setMember(members, slot.key, value); + } + /** A fresh snapshot: copies of the open containers, innermost first, each holding the copy of its open child. */ + #copyOpenPath(pending) { + if (this.#stack.length === 0) + return pending?.slot.kind === "root" ? pending.value : this.#root; + let child; + let childSlot; + for (let depth = this.#stack.length - 1; depth >= 0; depth--) { + const frame = this.#stack[depth]; + const copy = Array.isArray(frame.container) ? frame.container.slice() : { ...frame.container }; + if (depth === this.#stack.length - 1 && pending !== void 0) + setSlot(copy, pending.slot, pending.value); + if (child !== void 0 && childSlot !== void 0) + setSlot(copy, childSlot, child); + child = copy; + childSlot = frame.slot; + } + return childSlot?.kind === "root" ? child : this.#root; + } +}; +var ToolCallArgumentsStream = class { + #text = ""; + #parser = new StreamingJsonParser(); + #snapshot = void 0; + #arguments = {}; + /** The raw argument text received so far. */ + get text() { + return this.#text; + } + /** Append a piece of argument text; returns the partial arguments, a new object whenever they changed. */ + append(delta) { + this.#text += delta; + this.#parser.append(delta); + return this.#partial(); + } + /** + * Take the provider's complete argument text. When it continues the text so far, returns the missing piece as + * `delta`; otherwise starts over with `text`. Either way returns the partial arguments of `text`. + */ + replace(text) { + if (text.startsWith(this.#text)) { + const delta = text.slice(this.#text.length); + return { delta, arguments: delta === "" ? this.#partial() : this.append(delta) }; + } + this.#text = ""; + this.#parser = new StreamingJsonParser(); + return { delta: void 0, arguments: this.append(text) }; + } + /** The final arguments: one full parse of the complete text, which reports malformed arguments. */ + finish() { + return parseToolCallArguments(this.#text); + } + #partial() { + const snapshot = this.#parser.snapshot(); + if (snapshot === this.#snapshot) + return this.#arguments; + this.#snapshot = snapshot; + this.#arguments = typeof snapshot === "object" && snapshot !== null && !Array.isArray(snapshot) ? snapshot : {}; + return this.#arguments; + } +}; +var SIMPLE_ESCAPES = { + '"': '"', + "\\": "\\", + "/": "/", + b: "\b", + f: "\f", + n: "\n", + r: "\r", + t: " " +}; +function isDelimiter(char) { + return char === " " || char === " " || char === "\n" || char === "\r" || char === "," || char === ":" || char === "{" || char === "}" || char === "[" || char === "]" || char === '"'; +} +function isHexDigit(char) { + return char >= "0" && char <= "9" || char >= "a" && char <= "f" || char >= "A" && char <= "F"; +} +function scalarValue(token) { + for (const [literal, value] of LITERALS) + if (token === literal) + return value; + return NUMBER.test(token) ? Number(token) : void 0; +} +function pendingScalarValue(token) { + for (const [literal, value] of LITERALS) + if (literal.startsWith(token)) + return value; + if (NUMBER.test(token)) + return Number(token); + const mantissa = NUMBER_WITHOUT_EXPONENT_DIGITS.exec(token); + return mantissa === null ? void 0 : Number(mantissa[1]); +} +function setSlot(container, slot, value) { + if (slot.kind === "element") + container[slot.index] = value; + else if (slot.kind === "member") + setMember(container, slot.key, value); +} +function setMember(members, key, value) { + if (key === "__proto__") { + Object.defineProperty(members, key, { value, writable: true, enumerable: true, configurable: true }); + } else { + members[key] = value; + } +} + +// host/js/node_modules/@earendil-works/pi-ai/dist/providers/faux.js +var DEFAULT_API = "faux"; +var DEFAULT_PROVIDER = "faux"; +var DEFAULT_MODEL_ID = "faux-1"; +var DEFAULT_MODEL_NAME = "Faux Model"; +var DEFAULT_BASE_URL = "http://localhost:0"; +var DEFAULT_MIN_TOKEN_SIZE = 3; +var DEFAULT_MAX_TOKEN_SIZE = 5; +var DEFAULT_USAGE = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } +}; +function fauxText(text) { + return { type: "text", text }; +} +function fauxToolCall(name, arguments_, options = {}) { + return { + type: "toolCall", + id: options.id ?? randomId("tool"), + name, + arguments: arguments_ + }; +} +function normalizeFauxAssistantContent(content) { + if (typeof content === "string") { + return [fauxText(content)]; + } + return Array.isArray(content) ? content : [content]; +} +function fauxAssistantMessage(content, options = {}) { + return { + role: "assistant", + content: normalizeFauxAssistantContent(content), + api: DEFAULT_API, + provider: DEFAULT_PROVIDER, + model: DEFAULT_MODEL_ID, + usage: DEFAULT_USAGE, + stopReason: options.stopReason ?? "stop", + ...options.deferred === void 0 ? {} : { deferred: options.deferred }, + ...options.errorMessage === void 0 ? {} : { errorMessage: options.errorMessage }, + ...options.responseId === void 0 ? {} : { responseId: options.responseId }, + timestamp: options.timestamp ?? Date.now() + }; +} +function estimateTokens(text) { + return Math.ceil(text.length / 4); +} +function randomId(prefix) { + return `${prefix}:${Date.now()}:${Math.random().toString(36).slice(2)}`; +} +function contentToText(content) { + if (typeof content === "string") { + return content; + } + return content.map((block) => { + if (block.type === "text") { + return block.text; + } + return `[image:${block.mimeType}:${block.data.length}]`; + }).join("\n"); +} +function assistantContentToText(content) { + return content.map((block) => { + if (block.type === "text") { + return block.text; + } + if (block.type === "thinking") { + return block.thinking; + } + return `${block.name}:${JSON.stringify(block.arguments)}`; + }).join("\n"); +} +function toolResultToText(message) { + return [message.toolName, ...message.content.map((block) => contentToText([block]))].join("\n"); +} +function messageToText(message) { + if (message.role === "system") { + return [ + getSystemMessageText(message), + ...message.toolsRemoved?.map((tool) => `tool-:${JSON.stringify(tool)}`) ?? [], + ...message.toolsAdded?.map((tool) => `tool+:${JSON.stringify(tool)}`) ?? [] + ].filter((part) => part.length > 0).join("\n"); + } + if (message.role === "user") { + return contentToText(message.content); + } + if (message.role === "assistant") { + return assistantContentToText(message.content); + } + return toolResultToText(message); +} +function joinedLength(messages, count = messages.length) { + let length = count > 0 ? (count - 1) * 2 : 0; + for (let index = 0; index < count; index++) + length += messages[index].length; + return length; +} +function commonPromptPrefixLength(previous, current) { + let index = 0; + while (index < previous.length && index < current.length && previous[index] === current[index]) + index++; + const rest = (messages) => index === messages.length ? "" : (index > 0 ? "\n\n" : "") + messages.slice(index).join("\n\n"); + return joinedLength(previous, index) + commonPrefixLength(rest(previous), rest(current)); +} +function commonPrefixLength(a, b) { + const length = Math.min(a.length, b.length); + let index = 0; + while (index < length && a[index] === b[index]) { + index++; + } + return index; +} +function withUsageEstimate(message, context, options, promptCache) { + const prompt = context.messages.map((message2) => `${message2.role}:${messageToText(message2)}`); + const promptLength = joinedLength(prompt); + const promptTokens = Math.ceil(promptLength / 4); + const outputTokens = estimateTokens(assistantContentToText(message.content)); + let input = promptTokens; + let cacheRead = 0; + let cacheWrite = 0; + const sessionId = options?.sessionId; + if (sessionId && options?.cacheRetention !== "none") { + const previousPrompt = promptCache.get(sessionId); + if (previousPrompt) { + const cachedChars = commonPromptPrefixLength(previousPrompt, prompt); + cacheRead = Math.ceil(cachedChars / 4); + cacheWrite = Math.max(0, promptTokens - cacheRead); + } else { + cacheWrite = promptTokens; + } + input = 0; + promptCache.set(sessionId, prompt); + } + return { + ...message, + usage: { + input, + output: outputTokens, + cacheRead, + cacheWrite, + totalTokens: input + outputTokens + cacheRead + cacheWrite, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } + } + }; +} +function splitStringByTokenSize(text, minTokenSize, maxTokenSize) { + const chunks = []; + let index = 0; + while (index < text.length) { + const tokenSize = minTokenSize + Math.floor(Math.random() * (maxTokenSize - minTokenSize + 1)); + const charSize = Math.max(1, tokenSize * 4); + chunks.push(text.slice(index, index + charSize)); + index += charSize; + } + return chunks.length > 0 ? chunks : [""]; +} +function cloneMessage(message, api, provider, modelId) { + const cloned = structuredClone(message); + return { + ...cloned, + api, + provider, + model: modelId, + timestamp: cloned.timestamp ?? Date.now(), + usage: cloned.usage ?? DEFAULT_USAGE + }; +} +function createDeferredMessage(model, handle) { + return { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: DEFAULT_USAGE, + stopReason: "deferred", + deferred: handle, + timestamp: Date.now() + }; +} +function createErrorMessage(error, api, provider, modelId) { + return { + role: "assistant", + content: [], + api, + provider, + model: modelId, + usage: DEFAULT_USAGE, + stopReason: "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now() + }; +} +function createAbortedMessage(partial) { + return { + ...partial, + stopReason: "aborted", + errorMessage: "Request was aborted", + timestamp: Date.now() + }; +} +function scheduleChunk(chunk, tokensPerSecond) { + if (!tokensPerSecond || tokensPerSecond <= 0) { + return new Promise((resolve) => queueMicrotask(resolve)); + } + const delayMs = estimateTokens(chunk) / tokensPerSecond * 1e3; + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} +async function streamWithDeltas(stream, message, minTokenSize, maxTokenSize, tokensPerSecond, signal) { + const partial = { ...message, content: [], stopReason: "pending" }; + if (signal?.aborted) { + const aborted = createAbortedMessage(partial); + stream.push({ type: "error", reason: "aborted", error: aborted }); + stream.end(aborted); + return; + } + stream.push({ type: "start", partial: { ...partial } }); + for (let index = 0; index < message.content.length; index++) { + if (signal?.aborted) { + const aborted = createAbortedMessage(partial); + stream.push({ type: "error", reason: "aborted", error: aborted }); + stream.end(aborted); + return; + } + const block = message.content[index]; + if (block.type === "thinking") { + partial.content = [...partial.content, { type: "thinking", thinking: "" }]; + stream.push({ type: "thinking_start", contentIndex: index, partial: { ...partial } }); + for (const chunk of splitStringByTokenSize(block.thinking, minTokenSize, maxTokenSize)) { + await scheduleChunk(chunk, tokensPerSecond); + if (signal?.aborted) { + const aborted = createAbortedMessage(partial); + stream.push({ type: "error", reason: "aborted", error: aborted }); + stream.end(aborted); + return; + } + partial.content[index].thinking += chunk; + stream.push({ type: "thinking_delta", contentIndex: index, delta: chunk, partial: { ...partial } }); + } + stream.push({ + type: "thinking_end", + contentIndex: index, + content: block.thinking, + partial: { ...partial } + }); + continue; + } + if (block.type === "text") { + partial.content = [...partial.content, { type: "text", text: "" }]; + stream.push({ type: "text_start", contentIndex: index, partial: { ...partial } }); + for (const chunk of splitStringByTokenSize(block.text, minTokenSize, maxTokenSize)) { + await scheduleChunk(chunk, tokensPerSecond); + if (signal?.aborted) { + const aborted = createAbortedMessage(partial); + stream.push({ type: "error", reason: "aborted", error: aborted }); + stream.end(aborted); + return; + } + partial.content[index].text += chunk; + stream.push({ type: "text_delta", contentIndex: index, delta: chunk, partial: { ...partial } }); + } + stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: { ...partial } }); + continue; + } + partial.content = [...partial.content, { type: "toolCall", id: block.id, name: block.name, arguments: {} }]; + stream.push({ type: "toolcall_start", contentIndex: index, partial: { ...partial } }); + const toolArguments = new ToolCallArgumentsStream(); + for (const chunk of splitStringByTokenSize(JSON.stringify(block.arguments), minTokenSize, maxTokenSize)) { + await scheduleChunk(chunk, tokensPerSecond); + if (signal?.aborted) { + const aborted = createAbortedMessage(partial); + stream.push({ type: "error", reason: "aborted", error: aborted }); + stream.end(aborted); + return; + } + partial.content[index].arguments = toolArguments.append(chunk); + stream.push({ type: "toolcall_delta", contentIndex: index, delta: chunk, partial: { ...partial } }); + } + partial.content[index].arguments = block.arguments; + stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: { ...partial } }); + } + if (message.stopReason === "pending") { + throw new Error("Faux response ended without a stop reason"); + } + if (message.stopReason === "error" || message.stopReason === "aborted") { + stream.push({ type: "error", reason: message.stopReason, error: message }); + stream.end(message); + return; + } + stream.push({ type: "done", reason: message.stopReason, message }); + stream.end(message); +} +function createFauxCore(options) { + const api = options.api ?? randomId(DEFAULT_API); + const provider = options.provider ?? DEFAULT_PROVIDER; + const minTokenSize = Math.max(1, Math.min(options.tokenSize?.min ?? DEFAULT_MIN_TOKEN_SIZE, options.tokenSize?.max ?? DEFAULT_MAX_TOKEN_SIZE)); + const maxTokenSize = Math.max(minTokenSize, options.tokenSize?.max ?? DEFAULT_MAX_TOKEN_SIZE); + let pendingResponses = []; + const tokensPerSecond = options.tokensPerSecond; + const state = { callCount: 0, deferredFetchCount: 0, cancelledDeferred: [] }; + const promptCache = /* @__PURE__ */ new Map(); + const deferredResponses = /* @__PURE__ */ new Map(); + const modelDefinitions = options.models?.length ? options.models : [ + { + id: DEFAULT_MODEL_ID, + name: DEFAULT_MODEL_NAME, + reasoning: false, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128e3, + maxTokens: 16384 + } + ]; + const models = modelDefinitions.map((definition) => ({ + id: definition.id, + name: definition.name ?? definition.id, + api, + provider, + baseUrl: DEFAULT_BASE_URL, + reasoning: definition.reasoning ?? false, + input: definition.input ?? ["text", "image"], + inputLimits: definition.inputLimits, + cost: definition.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: definition.contextWindow ?? 128e3, + maxTokens: definition.maxTokens ?? 16384 + })); + const resolveResponse = async (step, context, streamOptions, requestModel) => { + const resolved = typeof step === "function" ? await step(context, streamOptions, state, requestModel) : step; + return withUsageEstimate(cloneMessage(resolved, api, provider, requestModel.id), context, streamOptions, promptCache); + }; + const stream = (requestModel, context, streamOptions) => { + const outer = createAssistantMessageEventStream(); + const step = pendingResponses.shift(); + state.callCount++; + queueMicrotask(async () => { + try { + await streamOptions?.onResponse?.({ status: 200, headers: {} }, requestModel); + if (!step) { + let message2 = createErrorMessage(new Error("No more faux responses queued"), api, provider, requestModel.id); + message2 = withUsageEstimate(message2, context, streamOptions, promptCache); + outer.push({ type: "error", reason: "error", error: message2 }); + outer.end(message2); + return; + } + if (streamOptions?.deferred) { + const handle = { + provider: requestModel.provider, + modelId: requestModel.id, + api: requestModel.api, + id: randomId("deferred"), + ...options.deferred?.pollAfterMs !== void 0 ? { pollAfterMs: options.deferred.pollAfterMs } : {} + }; + deferredResponses.set(handle.id, { + handle, + step, + context, + options: streamOptions, + model: requestModel, + pendingFetches: Math.max(0, Math.floor(options.deferred?.pendingFetches ?? 0)), + cancelled: false + }); + await streamWithDeltas(outer, createDeferredMessage(requestModel, handle), minTokenSize, maxTokenSize, tokensPerSecond, streamOptions.signal); + return; + } + const message = await resolveResponse(step, context, streamOptions, requestModel); + await streamWithDeltas(outer, message, minTokenSize, maxTokenSize, tokensPerSecond, streamOptions?.signal); + } catch (error) { + const message = createErrorMessage(error, api, provider, requestModel.id); + outer.push({ type: "error", reason: "error", error: message }); + outer.end(message); + } + }); + return outer; + }; + const streamSimple = (streamModel, context, streamOptions) => stream(streamModel, context, streamOptions); + const fetchDeferred = (requestModel, handle, fetchOptions) => { + const outer = createAssistantMessageEventStream(); + state.deferredFetchCount++; + queueMicrotask(async () => { + try { + await fetchOptions?.onResponse?.({ status: 200, headers: {} }, requestModel); + const entry = deferredResponses.get(handle.id); + if (!entry || entry.handle.provider !== handle.provider || entry.handle.modelId !== handle.modelId || entry.handle.api !== handle.api) { + throw new Error(`Unknown faux deferred response: ${handle.id}`); + } + if (entry.cancelled) + throw new Error(`Faux deferred response was cancelled: ${handle.id}`); + if (entry.pendingFetches > 0) { + entry.pendingFetches--; + await streamWithDeltas(outer, createDeferredMessage(requestModel, entry.handle), minTokenSize, maxTokenSize, tokensPerSecond, fetchOptions?.signal); + return; + } + if (!entry.final) { + const { deferred: _deferred, signal: _submissionSignal, onResponse: _submissionOnResponse, ...submissionOptions } = entry.options ?? {}; + try { + entry.final = await resolveResponse(entry.step, entry.context, submissionOptions, entry.model); + } catch (error) { + entry.final = createErrorMessage(error, api, provider, entry.model.id); + } + } + await streamWithDeltas(outer, entry.final, minTokenSize, maxTokenSize, tokensPerSecond, fetchOptions?.signal); + } catch (error) { + const message = createErrorMessage(error, api, provider, requestModel.id); + outer.push({ type: "error", reason: "error", error: message }); + outer.end(message); + } + }); + return outer; + }; + const cancelDeferred = async (requestModel, handle, cancelOptions) => { + state.cancelledDeferred.push(structuredClone(handle)); + const entry = deferredResponses.get(handle.id); + if (entry) + entry.cancelled = true; + await cancelOptions?.onResponse?.({ status: 200, headers: {} }, requestModel); + }; + function getModel(requestedModelId) { + if (!requestedModelId) { + return models[0]; + } + return models.find((candidate) => candidate.id === requestedModelId); + } + return { + api, + provider, + models, + stream, + streamSimple, + fetchDeferred, + cancelDeferred, + getModel, + state, + setResponses(responses) { + pendingResponses = [...responses]; + }, + appendResponses(responses) { + pendingResponses.push(...responses); + }, + getPendingResponseCount() { + return pendingResponses.length; + } + }; +} +function fauxProvider(options = {}) { + const core = createFauxCore(options); + const provider = createProvider({ + id: core.provider, + auth: { apiKey: { name: "Faux", resolve: async () => ({ auth: {} }) } }, + models: core.models, + api: { + stream: core.stream, + streamSimple: core.streamSimple, + fetchDeferred: core.fetchDeferred, + cancelDeferred: core.cancelDeferred + } + }); + return { + provider, + api: core.api, + models: core.models, + getModel: core.getModel, + state: core.state, + setResponses: core.setResponses, + appendResponses: core.appendResponses, + getPendingResponseCount: core.getPendingResponseCount + }; +} + +// host/js/p4-test.ts +var main = `fn greeting(name: &str) -> String { format!("hello {name}") } +fn main() { + let Some(name) = std::env::args().nth(1) else { std::process::exit(7) }; + println!("{}", serde_json::json!({"greeting": greeting(&name)})); +} +#[test] +fn greeting_test() { assert_eq!(greeting("world"), "hello world", "P4_expected_failure"); } +`; +var write = (path, text) => `cat > ${path} <<'P4_FILE' +${text} +P4_FILE +`; +var phases = [ + `set -e +mkdir -p /src/demo-cli/src /src/demo-node /tmp +cd /src/demo-cli +if test -d target; then cargo clean; fi +${write("Cargo.toml", '[package]\nname="demo-cli"\nversion="0.1.0"\nedition="2024"\n[workspace]\n')} +${write("src/main.rs", main.replace('"hello world", "P4_expected_failure"', '"hello wrong", "P4_expected_failure"'))} +cargo add serde_json +cargo fmt +set +e +cargo test -- --nocapture > /tmp/p4-fail.stdout 2> /tmp/p4-fail.stderr +code=$? +set -e +cat /tmp/p4-fail.stdout /tmp/p4-fail.stderr +test "$code" = 101 +case "$(cat /tmp/p4-fail.stderr)" in *P4_expected_failure*) ;; *) exit 1 ;; esac +printf 'P4_PHASE_0_OK +'`, + `set -e +cd /src/demo-cli +${write("src/main.rs", main)} +cargo fmt +cargo test -- --nocapture +cargo build --release +test -f target/wasm32-wasip2/release/demo-cli.wasm +sh -c '/src/demo-cli/target/wasm32-wasip2/release/demo-cli.wasm world' > /tmp/p4-cli.json +case "$(cat /tmp/p4-cli.json)" in *'"greeting":"hello world"'*) ;; *) exit 1 ;; esac +set +e +sh -c '/src/demo-cli/target/wasm32-wasip2/release/demo-cli.wasm' +code=$? +set -e +test "$code" = 7 +printf 'P4_PHASE_1_OK +'`, + `set -e +${write("/src/demo-node/package.json", '{"scripts":{"test":"node test.cjs"}}')} +${write("/src/demo-node/test.cjs", 'const fs = require("node:fs");\nrequire("node:assert/strict").equal(2 + 2, 4);\nfs.writeFileSync("result.txt", "P4 npm test passed\\n");')} +id=$(box create --ttl 600) +printf '%s +' "$id" > /tmp/p4-box-id +trap 'box rm "$id"' EXIT +box put "$id" /src/demo-node /work +box exec "$id" --timeout 120 -- sh -c 'cd /work && npm test' +box get "$id" /work/result.txt /src/demo-node/result.txt +test "$(cat /src/demo-node/result.txt)" = 'P4 npm test passed' +box rm "$id" +trap - EXIT +printf 'P4_PHASE_2_OK +'`, + `set -e +undo list --conversation 1 > /tmp/p4-undo.json +box ls > /tmp/p4-boxes +printf 'P4_PHASE_3_OK +'` +]; +function p4(host) { + const faux = fauxProvider({ provider: "openai", models: [{ id: "gpt-6.1-sol" }], tokensPerSecond: 1e6 }); + const respond = (transcript) => { + faux.appendResponses([respond]); + const messages = transcript.messages; + const input = messages.findLast((message) => message.role === "user"); + const text = (message) => typeof message.content === "string" ? message.content : (message.content ?? []).filter((part) => part.type === "text").map((part) => part.text).join("\n"); + const round = /P4 start (\d+)/.exec(text(input))?.[1]; + if (!round) throw new Error(`Unexpected P4 input: ${JSON.stringify(input)}`); + const last = messages.findLast((message) => message.role !== "system"); + let phase = 0; + if (last.role !== "user") { + const prior = /p4-\d+-(\d+)/.exec(last.toolCallId)?.[1]; + if (last.isError || prior === void 0 || !text(last).includes(`P4_PHASE_${prior}_OK`)) throw new Error(`P4 bash failed: ${JSON.stringify(last)}`); + phase = Number(prior) + 1; + } + if (phase === phases.length) return fauxAssistantMessage(`P4_COMPLETE_${round}`); + return fauxAssistantMessage([fauxToolCall("bash", { command: phases[phase], timeout: 300 }, { id: `p4-${round}-${phase}` })], { stopReason: "toolUse" }); + }; + faux.setResponses([respond]); + host.models().setProvider(faux.provider); + return { name: "p4" }; +} +export { + p4 as default +}; diff --git a/host/js/p4-test.ts b/host/js/p4-test.ts new file mode 100644 index 0000000..ee35cc4 --- /dev/null +++ b/host/js/p4-test.ts @@ -0,0 +1,86 @@ +/** P4 fixture provider for the real console: all project work uses the installed bash tool. */ +import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux'; + +const main = `fn greeting(name: &str) -> String { format!("hello {name}") } +fn main() { + let Some(name) = std::env::args().nth(1) else { std::process::exit(7) }; + println!("{}", serde_json::json!({"greeting": greeting(&name)})); +} +#[test] +fn greeting_test() { assert_eq!(greeting("world"), "hello world", "P4_expected_failure"); } +`; +const write = (path: string, text: string) => `cat > ${path} <<'P4_FILE'\n${text}\nP4_FILE\n`; +const phases = [ + `set -e +mkdir -p /src/demo-cli/src /src/demo-node /tmp +cd /src/demo-cli +if test -d target; then cargo clean; fi +${write('Cargo.toml', '[package]\nname="demo-cli"\nversion="0.1.0"\nedition="2024"\n[workspace]\n')} +${write('src/main.rs', main.replace('"hello world", "P4_expected_failure"', '"hello wrong", "P4_expected_failure"'))} +cargo add serde_json +cargo fmt +set +e +cargo test -- --nocapture > /tmp/p4-fail.stdout 2> /tmp/p4-fail.stderr +code=$? +set -e +cat /tmp/p4-fail.stdout /tmp/p4-fail.stderr +test "$code" = 101 +case "$(cat /tmp/p4-fail.stderr)" in *P4_expected_failure*) ;; *) exit 1 ;; esac +printf 'P4_PHASE_0_OK\n'`, + `set -e +cd /src/demo-cli +${write('src/main.rs', main)} +cargo fmt +cargo test -- --nocapture +cargo build --release +test -f target/wasm32-wasip2/release/demo-cli.wasm +sh -c '/src/demo-cli/target/wasm32-wasip2/release/demo-cli.wasm world' > /tmp/p4-cli.json +case "$(cat /tmp/p4-cli.json)" in *'"greeting":"hello world"'*) ;; *) exit 1 ;; esac +set +e +sh -c '/src/demo-cli/target/wasm32-wasip2/release/demo-cli.wasm' +code=$? +set -e +test "$code" = 7 +printf 'P4_PHASE_1_OK\n'`, + `set -e +${write('/src/demo-node/package.json', '{"scripts":{"test":"node test.cjs"}}')} +${write('/src/demo-node/test.cjs', 'const fs = require("node:fs");\nrequire("node:assert/strict").equal(2 + 2, 4);\nfs.writeFileSync("result.txt", "P4 npm test passed\\n");')} +id=$(box create --ttl 600) +printf '%s\n' "$id" > /tmp/p4-box-id +trap 'box rm "$id"' EXIT +box put "$id" /src/demo-node /work +box exec "$id" --timeout 120 -- sh -c 'cd /work && npm test' +box get "$id" /work/result.txt /src/demo-node/result.txt +test "$(cat /src/demo-node/result.txt)" = 'P4 npm test passed' +box rm "$id" +trap - EXIT +printf 'P4_PHASE_2_OK\n'`, + `set -e +undo list --conversation 1 > /tmp/p4-undo.json +box ls > /tmp/p4-boxes +printf 'P4_PHASE_3_OK\n'`, +]; +export default function p4(host: any) { + // Replace the console's transport before resume. No real model request can occur. + const faux = fauxProvider({ provider: 'openai', models: [{ id: 'gpt-6.1-sol' }], tokensPerSecond: 1000000 }); + const respond = (transcript: any): any => { + faux.appendResponses([respond]); + const messages = transcript.messages; + const input = messages.findLast((message: any) => message.role === 'user'); + const text = (message: any) => typeof message.content === 'string' ? message.content : (message.content ?? []).filter((part: any) => part.type === 'text').map((part: any) => part.text).join('\n'); + const round = /P4 start (\d+)/.exec(text(input))?.[1]; + if (!round) throw new Error(`Unexpected P4 input: ${JSON.stringify(input)}`); + const last = messages.findLast((message: any) => message.role !== 'system'); + let phase = 0; + if (last.role !== 'user') { + const prior = /p4-\d+-(\d+)/.exec(last.toolCallId)?.[1]; + if (last.isError || prior === undefined || !text(last).includes(`P4_PHASE_${prior}_OK`)) throw new Error(`P4 bash failed: ${JSON.stringify(last)}`); + phase = Number(prior) + 1; + } + if (phase === phases.length) return fauxAssistantMessage(`P4_COMPLETE_${round}`); + return fauxAssistantMessage([fauxToolCall('bash', { command: phases[phase], timeout: 300 }, { id: `p4-${round}-${phase}` })], { stopReason: 'toolUse' }); + }; + faux.setResponses([respond]); + host.models().setProvider(faux.provider); + return { name: 'p4' }; +} diff --git a/host/js/package.json b/host/js/package.json index 02ce1bf..1f02308 100644 --- a/host/js/package.json +++ b/host/js/package.json @@ -1,7 +1,7 @@ { "private": true, "type": "module", - "scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js && esbuild watchdog-test.ts --bundle --format=esm --platform=browser --outfile=watchdog-test-bundle.js && esbuild workspaces-test.ts --bundle --format=esm --platform=browser --outfile=workspaces-test-bundle.js && esbuild goal-questions-test.ts --bundle --format=esm --platform=browser --outfile=goal-questions-test-bundle.js && esbuild dream-test.ts --bundle --format=esm --platform=browser --outfile=dream-test-bundle.js" }, + "scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js && esbuild watchdog-test.ts --bundle --format=esm --platform=browser --outfile=watchdog-test-bundle.js && esbuild workspaces-test.ts --bundle --format=esm --platform=browser --outfile=workspaces-test-bundle.js && esbuild goal-questions-test.ts --bundle --format=esm --platform=browser --outfile=goal-questions-test-bundle.js && esbuild dream-test.ts --bundle --format=esm --platform=browser --outfile=dream-test-bundle.js && esbuild p4-test.ts --bundle --format=esm --platform=browser --outfile=p4-test-bundle.js" }, "dependencies": { "@earendil-works/pi-durable": "1.5.0", "@earendil-works/pi-ai": "1.5.0", diff --git a/host/src/builder/README.md b/host/src/builder/README.md index 0056cec..d126eb6 100644 --- a/host/src/builder/README.md +++ b/host/src/builder/README.md @@ -63,6 +63,6 @@ It does not depend on a user-profile path or a host HOME file. Development commands retain the global Cargo configuration and its kache wrapper. The sandbox uses a private Cargo home and selects kache through `--config build.rustc-wrapper`. -For `cargo add`, the network phase performs a dry run to populate the registry index. -The offline command makes the actual manifest change inside the namespaces. +For `cargo add`, the network phase updates the private source copy and fetches the new dependencies, including transitive dependencies. +The command then repeats offline inside the namespaces before source publication. The disk cap applies after each completed command, including compiler failures. diff --git a/host/src/builder/mod.rs b/host/src/builder/mod.rs index b1c17e2..b8b9cbb 100644 --- a/host/src/builder/mod.rs +++ b/host/src/builder/mod.rs @@ -134,7 +134,8 @@ impl Builder { }; let mut fetch = Command::new("cargo"); if action == "add" { - fetch.args(&request.argv).arg("--dry-run"); + // Resolve the new manifest before fetch, including transitive dependencies. + fetch.args(&request.argv); } else { fetch.arg("fetch"); } @@ -144,6 +145,14 @@ impl Builder { } else { run(fetch, tx, deadline).await?.0 }; + if code == 0 && action == "add" { + let mut fetch = Command::new("cargo"); + fetch + .arg("fetch") + .current_dir(&cwd) + .env("CARGO_HOME", ®istry); + code = run(fetch, tx, deadline).await?.0; + } let mut executables = Vec::new(); if code == 0 { let tools = base.join("tools"); diff --git a/host/tests/p4.rs b/host/tests/p4.rs new file mode 100644 index 0000000..ef166d4 --- /dev/null +++ b/host/tests/p4.rs @@ -0,0 +1,277 @@ +//! P4 exit: the real console agent builds Rust and Node projects, then inspects undo history. +use anyhow::{Context, ensure}; +use sausage::world::World; +use sausage_vfs::journal::Entry; +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; + +fn checked(command: &mut Command) -> anyhow::Result<()> { + ensure!(command.status()?.success(), "command failed: {command:?}"); + Ok(()) +} +fn prepare(dir: &Path) -> anyhow::Result { + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let userland = source.join("userland"); + let target = PathBuf::from(std::env::var("CARGO_TARGET_DIR")?); + let bundle = Path::new(env!("CARGO_TARGET_TMPDIR")).join("p4-bundle"); + let revision = Command::new("git") + .current_dir(&source) + .args(["rev-parse", "HEAD"]) + .output()?; + ensure!(revision.status.success(), "fixture revision"); + if fs::read(bundle.join("revision")).ok().as_deref() != Some(revision.stdout.as_slice()) { + if !userland.join("cwd-init/init.o").exists() { + checked(Command::new("nu").arg(userland.join("cwd-init/build.nu")))?; + } + checked(Command::new("cargo").current_dir(&userland).args([ + "build", + "--release", + "--locked", + "-p", + "git", + "-p", + "world-cargo", + "-p", + "box-cli", + "-p", + "undo", + "-p", + "sausage-coreutils", + ]))?; + checked(Command::new("nu").arg(userland.join("brush/build.nu")))?; + fs::create_dir_all(bundle.join("bin"))?; + fs::create_dir_all(bundle.join("src"))?; + for (binary, name) in [ + ("brush", "sh"), + ("brush", "bash"), + ("git", "git"), + ("cargo", "cargo"), + ("box", "box"), + ("undo", "undo"), + ("cat", "cat"), + ("mkdir", "mkdir"), + ] { + fs::copy( + target.join(format!("wasm32-wasip2/release/{binary}.wasm")), + bundle.join(format!("bin/{name}.wasm")), + )?; + } + fs::write(bundle.join("revision"), revision.stdout)?; + } + World::open_with_userland(dir, Some(&bundle))?; + let repo = dir.join("world/src/sausage"); + fs::create_dir_all(repo.join("extensions/p4"))?; + fs::write( + repo.join("extensions/p4/server.ts"), + include_str!("../js/p4-test-bundle.js"), + )?; + for args in [ + vec!["init", "-b", "main"], + vec!["add", "."], + vec!["commit", "--no-gpg-sign", "-m", "P4 fixture"], + ] { + checked( + Command::new("git") + .current_dir(&repo) + .args(args) + .env("GIT_AUTHOR_NAME", "P4 test") + .env("GIT_AUTHOR_EMAIL", "p4@example.com") + .env("GIT_COMMITTER_NAME", "P4 test") + .env("GIT_COMMITTER_EMAIL", "p4@example.com"), + )?; + } + Ok(bundle) +} +struct Running(Child); +impl Drop for Running { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} +fn round(dir: &Path, bundle: &Path, number: usize) -> anyhow::Result<()> { + let stdout = dir.join(format!("round-{number}.stdout")); + let stderr = dir.join(format!("round-{number}.stderr")); + let mut child = Running( + Command::new(env!("CARGO_BIN_EXE_sausage")) + .arg("run") + .arg(dir) + .arg("--userland") + .arg(bundle) + .args(["--api-listen", "127.0.0.1:0"]) + .env("PI_PROXY_BASE_URL", "https://proxy.invalid") + .env("PI_PROXY_API_KEY", "faux-token") + .env_remove("CREDENTIALS_DIRECTORY") + .stdin(Stdio::piped()) + .stdout(fs::File::create(&stdout)?) + .stderr(fs::File::create(&stderr)?) + .spawn()?, + ); + writeln!( + child.0.stdin.take().context("console stdin")?, + "P4 start {number}" + )?; + let deadline = Instant::now() + Duration::from_secs(600); + loop { + if fs::read_to_string(&stdout)?.contains(&format!("P4_COMPLETE_{number}")) { + break; + } + if let Some(status) = child.0.try_wait()? { + anyhow::bail!("console exited: {status}"); + } + ensure!( + Instant::now() < deadline, + "P4 round {number} exceeded its deadline" + ); + std::thread::yield_now(); + } + checked(Command::new("kill").args(["-TERM", &child.0.id().to_string()]))?; + let deadline = Instant::now() + Duration::from_secs(20); + loop { + if let Some(status) = child.0.try_wait()? { + ensure!(status.success(), "console shutdown: {status}"); + break; + } + ensure!( + Instant::now() < deadline, + "console shutdown exceeded its deadline" + ); + std::thread::yield_now(); + } + Ok(()) +} +fn boxes_removed(dir: &Path, bundle: &Path) -> anyhow::Result<()> { + let path = dir.join("world/tmp/p4-box-id"); + if !path.exists() { + return Ok(()); + } + let id = fs::read_to_string(path)?; + let id = id.trim(); + ensure!( + id.starts_with("tc-") && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'), + "invalid box ID" + ); + // The agent's EXIT trap is primary cleanup. Recover its box even if the console fails. + let output = Command::new(env!("CARGO_BIN_EXE_sausage")) + .arg("sh") + .arg(dir) + .arg("--userland") + .arg(bundle) + .arg("-c") + .arg("box ls") + .env_remove("CREDENTIALS_DIRECTORY") + .output()?; + ensure!( + output.status.success(), + "box ls: {}", + String::from_utf8_lossy(&output.stderr) + ); + if String::from_utf8_lossy(&output.stdout).contains(id) { + checked( + Command::new(env!("CARGO_BIN_EXE_sausage")) + .arg("sh") + .arg(dir) + .arg("--userland") + .arg(bundle) + .arg("-c") + .arg(format!("box rm {id}")) + .env_remove("CREDENTIALS_DIRECTORY"), + )?; + anyhow::bail!("Agent left {id} active. Test cleanup removed it"); + } + println!("P4 box absent from service inventory: {id}"); + Ok(()) +} +fn cache_hits(dir: &Path) -> anyhow::Result { + let workspace = fs::read_dir(dir.join("host/builds"))? + .next() + .context("builder workspace")?? + .path(); + let output = Command::new("kache") + .arg("stats") + .env("KACHE_CACHE_DIR", workspace.join("kache")) + .output()?; + ensure!( + output.status.success(), + "kache: {}", + String::from_utf8_lossy(&output.stderr) + ); + let text = String::from_utf8(output.stdout)?; + println!("P4 PRIVATE CACHE:\n{text}"); + let words: Vec<_> = text.split_whitespace().collect(); + words + .windows(2) + .find_map(|pair| { + if pair[1] == "of" { + pair[0].replace(',', "").parse().ok() + } else { + None + } + }) + .context("cache hit count") +} +#[test] +fn agent_builds_rust_and_node_twice() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let bundle = prepare(dir.path())?; + let mut first_hits = 0; + for number in 1..=2 { + let outcome = round(dir.path(), &bundle, number); + let cleanup = boxes_removed(dir.path(), &bundle); + if outcome.is_err() { + for stream in ["stdout", "stderr"] { + eprintln!( + "P4 round {number} {stream}:\n{}", + fs::read_to_string(dir.path().join(format!("round-{number}.{stream}")))? + ); + } + } + outcome?; + cleanup?; + let world = dir.path().join("world"); + ensure!( + world + .join("src/demo-cli/target/wasm32-wasip2/release/demo-cli.wasm") + .is_file(), + "missing world artifact" + ); + ensure!( + fs::read_to_string(world.join("src/demo-cli/Cargo.toml"))?.contains("serde_json"), + "cargo add did not reach world" + ); + ensure!( + fs::read_to_string(world.join("src/demo-node/result.txt"))? == "P4 npm test passed\n", + "Node result missing" + ); + let entries: Vec = + serde_json::from_slice(&fs::read(world.join("tmp/p4-undo.json"))?)?; + for (phase, path) in [ + (0, "/src/demo-cli/Cargo.toml"), + (1, "/src/demo-cli/src/main.rs"), + ] { + let tag = format!("p4-{number}-{phase}"); + ensure!( + entries.iter().any(|entry| entry.tag == tag + && entry.program == "bash" + && entry.changed_paths.iter().any(|p| p == path)), + "undo lost {tag}: {path}" + ); + } + let hits = cache_hits(dir.path())?; + if number == 1 { + first_hits = hits; + } else { + ensure!( + hits > first_hits, + "second run missed kache: {first_hits} -> {hits}" + ); + println!("P4 CACHE SECOND RUN: {first_hits} -> {hits} hits"); + } + } + Ok(()) +}