diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index deba2e01b..06470a766 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -851,7 +851,10 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( if (toolCall.id) block.id = toolCall.id; if (toolCall.function?.name) block.name = toolCall.function.name; let delta = ""; - const rawArgs: unknown = toolCall.function?.arguments; + // The OpenAI SDK types `function.arguments` as a JSON string, but MiniMax-compatible + // hosts stream a fully-formed object instead. Model both shapes so the branches below + // narrow honestly rather than widening through `unknown`. + const rawArgs = toolCall.function?.arguments as string | Record | undefined; if (typeof rawArgs === "string") { if (rawArgs.length > 0) { delta = rawArgs; @@ -868,10 +871,9 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( // single delta instead of the OpenAI JSON-string contract. Hold the object directly // — no `[object Object]` round-trip through the string buffer — and serialize once for // the wire delta that proxy servers forward verbatim as `input_json_delta`. - const objectArgs = rawArgs as Record; - block.partialArgs = objectArgs; - block.arguments = objectArgs; - delta = JSON.stringify(objectArgs); + block.partialArgs = rawArgs; + block.arguments = rawArgs; + delta = JSON.stringify(rawArgs); } stream.push({ type: "toolcall_delta", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b529e555b..1015badc0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -26,7 +26,6 @@ ### Changed - Changed the `task` tool description to tag read-only agents and explicitly forbid assigning them file edits/commands or offloading reasoning to `quick_task`/`explore`. - - Changed Redis and SQL session storage initialization to load only indexed metadata (`size`, `mtimeMs`) instead of full session content - Changed `SessionStorage` read paths to rely on backend-backed metadata/indexed storage, so session content is fetched on demand rather than cached as full in-memory mirrors - Changed session-list slice reads to go through `SessionStorage.readTextSlices` across all backends, removing the file-only single-open branch and caller-managed buffers. `FileSessionStorage` now reads both windows via `peekFileEnds`, while Redis and SQL backends encode session content once per combined read. @@ -37,6 +36,8 @@ ### Fixed +- Fixed tiny-model startup in compiled binaries by resolving `@huggingface/transformers` and its runtime dependencies from the installed cache using `package.json` `exports`/`main` metadata, preventing module-resolution failures when launching models +- Fixed tiny runtime installation flow in compiled binaries by using the build-time resolved `@huggingface/transformers` version and ensuring the runtime lock directory’s parent exists before acquiring the install lock, preventing mismatch and setup failures on fresh installs - Fixed the terminal protocol debug probe reusing one stable Kitty graphics id across repeated panels, which could move/replace an earlier swatch instead of rendering a new one. - Fixed selector dialogs (the `ask` tool, hook prompts) collapsing to a single visible option on shorter terminals when options carried long descriptions: the highlighted option's wrapped description consumed the entire row budget, hiding every other option and making the menu feel unnavigable (down moved the lone visible entry, left/right did nothing). When the fully-expanded list overflows, `HookSelectorComponent` now renders a compact list — every option label stays on screen and only the highlighted option expands its description, truncated to the remaining rows — so the whole menu is always visible and the detail pane follows the cursor. - Fixed `read` failing with "Path not found" on web URLs whose scheme `//` collapsed to a single `/` (e.g. `https:/github.com/...`), which happens when a URL is routed through Node's `path.normalize`/`path.resolve`. The fetch URL recognizer now accepts a single-slash scheme and repairs it back to `//` before fetching, so collapsed URLs resolve instead of falling through to filesystem lookup. @@ -9292,4 +9293,4 @@ Initial public release. - Git branch display in footer - Message queueing during streaming responses - OAuth integration for Gmail and Google Calendar access -- HTML export with syntax highlighting and collapsible sections +- HTML export with syntax highlighting and collapsible sections \ No newline at end of file diff --git a/packages/coding-agent/scripts/build-binary.ts b/packages/coding-agent/scripts/build-binary.ts index d0b663f07..97376a758 100644 --- a/packages/coding-agent/scripts/build-binary.ts +++ b/packages/coding-agent/scripts/build-binary.ts @@ -1,10 +1,20 @@ #!/usr/bin/env bun +import { createRequire } from "node:module"; import * as path from "node:path"; const packageDir = path.join(import.meta.dir, ".."); const outputPath = path.join(packageDir, "dist", "omp"); +// Transformers.js is an optional, native-heavy dependency that is never bundled +// into the binary; the tiny-model worker `bun install`s it into a runtime cache +// on first use. The `catalog:` spec cannot be resolved from inside the compiled +// bunfs (issue #1763), so embed the concrete installed version here for the +// worker to pin its runtime install against. +const transformersVersion = ( + createRequire(import.meta.url)("@huggingface/transformers/package.json") as { version: string } +).version; + function shouldAdhocSignDarwinBinary(): boolean { return process.platform === "darwin"; } @@ -40,6 +50,8 @@ async function main(): Promise { "--keep-names", "--define", 'process.env.PI_COMPILED="true"', + "--define", + `process.env.PI_TINY_TRANSFORMERS_VERSION=${JSON.stringify(transformersVersion)}`, "--external", "mupdf", "--root", diff --git a/packages/coding-agent/src/tiny/compiled-runtime.ts b/packages/coding-agent/src/tiny/compiled-runtime.ts new file mode 100644 index 000000000..045f30db3 --- /dev/null +++ b/packages/coding-agent/src/tiny/compiled-runtime.ts @@ -0,0 +1,179 @@ +import * as fs from "node:fs"; +import * as Module from "node:module"; +import * as path from "node:path"; + +/** + * Bun's compiled-binary module resolver only finds `/index.js` for bare + * specifiers loaded from the *real* filesystem — it ignores `main`/`exports` + * (issue #1763). The tiny-model Transformers.js runtime is `bun install`ed into + * a cache directory at runtime, and its graph (`@huggingface/transformers` → + * `onnxruntime-node` → `onnxruntime-common`, plus an eager `require("sharp")`) + * all point `main`/`exports` at nested files, so the stock resolver cannot load + * any of them. We patch `Module._resolveFilename` to resolve those bare + * specifiers against the cache ourselves, honoring `main`/`exports`. + * + * This module is filesystem-pure aside from {@link installRuntimeModuleResolver} + * mutating the `node:module` resolver, so the resolution logic is unit-testable + * without a compiled binary. + */ + +/** Conditions honored when resolving an `exports` map for a CommonJS `require`. */ +const RUNTIME_CONDITIONS: Record = { node: true, require: true, default: true }; + +/** Extension probes appended to a `main`/`exports` target that lacks one. */ +const RUNTIME_EXTENSIONS: readonly string[] = [".js", ".cjs", ".mjs", ".json", ".node"]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Walk a conditional `exports` target (string, array of fallbacks, or a + * condition object) and return the first relative path that matches a runtime + * condition in declaration order. Returns `null` when nothing applies (e.g. + * `import`-only targets). + */ +export function selectConditionalTarget(target: unknown): string | null { + if (typeof target === "string") return target; + if (Array.isArray(target)) { + for (const entry of target) { + const resolved = selectConditionalTarget(entry); + if (resolved) return resolved; + } + return null; + } + if (isRecord(target)) { + for (const condition in target) { + if (!RUNTIME_CONDITIONS[condition]) continue; + const resolved = selectConditionalTarget(target[condition]); + if (resolved) return resolved; + } + } + return null; +} + +/** Resolve a relative target inside a package to a concrete file path, probing extensions and `index`. */ +function resolveFileTarget(pkgDir: string, relative: string): string | null { + const base = path.join(pkgDir, relative); + const candidates = [base, ...RUNTIME_EXTENSIONS.map(ext => base + ext)]; + for (const candidate of candidates) { + try { + const stat = fs.statSync(candidate); + if (stat.isFile()) return candidate; + if (stat.isDirectory()) { + const indexed = resolveFileTarget(candidate, "index"); + if (indexed) return indexed; + } + } catch { + // missing candidate — keep probing + } + } + return null; +} + +function resolveExportsEntry( + pkgDir: string, + exports: Record, + subpath: string | undefined, +): string | null { + let subpathMap = false; + for (const key in exports) { + subpathMap = key === "." || key.startsWith("./"); + break; + } + if (subpathMap) { + const key = subpath ? `./${subpath}` : "."; + if (!(key in exports)) return null; + const target = selectConditionalTarget(exports[key]); + return target ? resolveFileTarget(pkgDir, target) : null; + } + // A bare condition map only describes the package root, so a subpath + // request falls through to plain path joining at the call site. + if (subpath) return null; + const target = selectConditionalTarget(exports); + return target ? resolveFileTarget(pkgDir, target) : null; +} + +/** + * Split a bare specifier into its package name and optional subpath, handling + * scoped packages (`@scope/name/sub` → `@scope/name` + `sub`). + */ +export function splitBareSpecifier(specifier: string): { packageName: string; subpath: string | undefined } { + const segments = specifier.split("/"); + const take = specifier.startsWith("@") ? 2 : 1; + const packageName = segments.slice(0, take).join("/"); + const subpath = segments.length > take ? segments.slice(take).join("/") : undefined; + return { packageName, subpath }; +} + +/** + * Resolve a bare specifier against an installed `node_modules` directory, + * honoring `exports` (CommonJS conditions), then `main`, then `index.js`. + * Returns an absolute file path, or `null` when the package/entry is absent. + */ +export function resolveRuntimeModule(runtimeNodeModules: string, specifier: string): string | null { + const { packageName, subpath } = splitBareSpecifier(specifier); + const pkgDir = path.join(runtimeNodeModules, ...packageName.split("/")); + const manifest = readManifest(pkgDir); + if (!manifest) return subpath ? resolveFileTarget(pkgDir, subpath) : null; + + const { exports } = manifest; + if (typeof exports === "string" || isRecord(exports)) { + const map = typeof exports === "string" ? { ".": exports } : exports; + const resolved = resolveExportsEntry(pkgDir, map, subpath); + if (resolved) return resolved; + } + if (subpath) return resolveFileTarget(pkgDir, subpath); + if (typeof manifest.main === "string") { + const resolved = resolveFileTarget(pkgDir, manifest.main); + if (resolved) return resolved; + } + return resolveFileTarget(pkgDir, "index.js"); +} + +function readManifest(pkgDir: string): Record | null { + try { + const parsed: unknown = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8")); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +interface ModuleResolver { + _resolveFilename(request: string, parent: unknown, isMain: boolean, options?: unknown): string; +} + +const PATCHED = Symbol.for("omp.tiny.compiledRuntimeResolver"); + +interface ResolverOptions { + /** Absolute path to the runtime cache's `node_modules`. */ + runtimeNodeModules: string; + /** Bare specifier → absolute file path overrides (e.g. `sharp` → no-op stub). */ + stubs?: Record; +} + +/** + * Patch `node:module`'s resolver (idempotently) so bare specifiers that the + * stock compiled-binary resolver cannot find fall back to the runtime cache. + * Stock resolution is tried first, so this never changes behavior for modules + * that already resolve (the worker's own bundled imports, node builtins). + */ +export function installRuntimeModuleResolver({ runtimeNodeModules, stubs = {} }: ResolverOptions): void { + const resolver = (Module as unknown as { default?: ModuleResolver } & ModuleResolver).default ?? Module; + const target = resolver as unknown as ModuleResolver & { [PATCHED]?: boolean }; + if (target[PATCHED]) return; + const original = target._resolveFilename.bind(target); + target._resolveFilename = (request: string, parent: unknown, isMain: boolean, options?: unknown): string => { + try { + return original(request, parent, isMain, options); + } catch (error) { + const stub = stubs[request]; + if (stub) return stub; + const resolved = resolveRuntimeModule(runtimeNodeModules, request); + if (resolved) return resolved; + throw error; + } + }; + target[PATCHED] = true; +} diff --git a/packages/coding-agent/src/tiny/worker.ts b/packages/coding-agent/src/tiny/worker.ts index 118838a67..416a9aede 100644 --- a/packages/coding-agent/src/tiny/worker.ts +++ b/packages/coding-agent/src/tiny/worker.ts @@ -10,6 +10,7 @@ import type { import { getTinyModelsCacheDir, isCompiledBinary, prompt } from "@oh-my-pi/pi-utils"; import packageJson from "../../package.json" with { type: "json" }; import tinyTitleSystemPrompt from "../prompts/system/tiny-title-system.md" with { type: "text" }; +import { installRuntimeModuleResolver, resolveRuntimeModule } from "./compiled-runtime"; import { resolveTinyModelDevicePreference, type TinyModelDevice, tinyModelDeviceLoadOrder } from "./device"; import { resolveTinyModelDtypeOverride, type TinyModelDtype } from "./dtype"; import { @@ -28,6 +29,7 @@ const STOP_DECODE_WINDOW_TOKENS = 32; const MEMORY_COMPLETION_MAX_NEW_TOKENS = 256; const TINY_TITLE_SYSTEM_PROMPT = prompt.render(tinyTitleSystemPrompt); const TRANSFORMERS_PACKAGE = "@huggingface/transformers"; +const COMPILED_TRANSFORMERS_VERSION = process.env.PI_TINY_TRANSFORMERS_VERSION; const sourceRequire = createRequire(import.meta.url); const INSTALL_LOCK_ATTEMPTS = 240; const INSTALL_LOCK_SLEEP_MS = 250; @@ -67,6 +69,7 @@ function resolveTransformersVersionSpec(): string { manifest.optionalDependencies?.[TRANSFORMERS_PACKAGE] ?? manifest.dependencies?.[TRANSFORMERS_PACKAGE]; if (!versionSpec) throw new Error(`${TRANSFORMERS_PACKAGE} is missing from package.json optionalDependencies`); if (!versionSpec.startsWith("catalog:")) return versionSpec; + if (COMPILED_TRANSFORMERS_VERSION) return COMPILED_TRANSFORMERS_VERSION; const installed = sourceRequire(`${TRANSFORMERS_PACKAGE}/package.json`) as { version: string }; return installed.version; } @@ -117,6 +120,7 @@ function getTinyTitleRuntimeDir(): string { async function acquireInstallLock(runtimeDir: string): Promise<() => Promise> { const lockDir = `${runtimeDir}.lock`; + await fs.mkdir(path.dirname(lockDir), { recursive: true }); for (let attempt = 0; attempt < INSTALL_LOCK_ATTEMPTS; attempt++) { try { await fs.mkdir(lockDir); @@ -216,6 +220,23 @@ async function ensureCompiledTransformersRuntime( } } +/** + * Prepare the freshly-installed compiled runtime for loading: stub `sharp` + * (the tiny models are text-generation only, so the native image pipeline is + * dead weight) and patch the module resolver so Transformers.js's bare requires + * (`onnxruntime-node`, `onnxruntime-common`) resolve against the cache. Returns + * the absolute Transformers.js entrypoint to `require`. + */ +async function prepareCompiledRuntime(runtimeDir: string): Promise { + const nodeModules = path.join(runtimeDir, "node_modules"); + const sharpStub = path.join(runtimeDir, "omp-sharp-stub.cjs"); + await Bun.write(sharpStub, "module.exports = {};\n"); + installRuntimeModuleResolver({ runtimeNodeModules: nodeModules, stubs: { sharp: sharpStub } }); + const entry = resolveRuntimeModule(nodeModules, TRANSFORMERS_PACKAGE); + if (!entry) throw new Error(`Unable to resolve ${TRANSFORMERS_PACKAGE} in compiled runtime at ${nodeModules}`); + return entry; +} + function configureTransformers(transformers: TransformersRuntime): TransformersRuntime { transformers.env.cacheDir = getTinyModelsCacheDir(); transformers.env.allowLocalModels = false; @@ -232,8 +253,9 @@ async function loadTransformers( transformersRuntime = (async () => { if (!isCompiledBinary()) return configureTransformers(sourceRequire(TRANSFORMERS_PACKAGE) as TransformersRuntime); const runtimeDir = await ensureCompiledTransformersRuntime(transport, requestId, modelKey); - const require_ = createRequire(path.join(runtimeDir, "package.json")); - return configureTransformers(require_(TRANSFORMERS_PACKAGE) as TransformersRuntime); + const entry = await prepareCompiledRuntime(runtimeDir); + const require_ = createRequire(entry); + return configureTransformers(require_(entry) as TransformersRuntime); })().catch(error => { transformersRuntime = null; throw error; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 0b5d807b7..d56cd7d42 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1525,7 +1525,10 @@ export class TUI extends Container { cursorPos === null ? null : { row: paddedViewportTop + (cursorPos.row - newViewportTop), col: cursorPos.col }; - return { kind: "deferredMutation", cursor: { position: mappedCursor, totalLines: this.#previousLines.length } }; + return { + kind: "deferredMutation", + cursor: { position: mappedCursor, totalLines: this.#previousLines.length }, + }; } return { kind: "deferredShrink", paddedLength: this.#previousLines.length }; } diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index 48b43950b..60f8518d8 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -2067,13 +2067,27 @@ describe("TUI terminal-state regressions", () => { try { tui.start(); await settle(term); - expect(visible(term).map(l => l.trim())).toEqual(["line-24", "line-25", "line-26", "line-27", "line-28", "line-29"]); + expect(visible(term).map(l => l.trim())).toEqual([ + "line-24", + "line-25", + "line-26", + "line-27", + "line-28", + "line-29", + ]); // Delete one offscreen row (index 5): the last six rows are unchanged, // only their absolute indices shift. component.setLines([...initial.slice(0, 5), ...initial.slice(6)]); tui.requestRender(true, { allowUnknownViewportMutation: true }); await settle(term); - expect(visible(term).map(l => l.trim())).toEqual(["line-24", "line-25", "line-26", "line-27", "line-28", "line-29"]); + expect(visible(term).map(l => l.trim())).toEqual([ + "line-24", + "line-25", + "line-26", + "line-27", + "line-28", + "line-29", + ]); } finally { Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); tui.stop(); @@ -2096,7 +2110,14 @@ describe("TUI terminal-state regressions", () => { component.setLines([...initial.slice(0, 5), ...initial.slice(6, -1), "prompt-updated"]); tui.requestRender(true, { allowUnknownViewportMutation: true }); await settle(term); - expect(visible(term).map(l => l.trim())).toEqual(["line-24", "line-25", "line-26", "line-27", "line-28", "prompt-updated"]); + expect(visible(term).map(l => l.trim())).toEqual([ + "line-24", + "line-25", + "line-26", + "line-27", + "line-28", + "prompt-updated", + ]); } finally { Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); tui.stop(); @@ -2121,7 +2142,14 @@ describe("TUI terminal-state regressions", () => { component.setLines([...initial.slice(0, 5), ...initial.slice(6, -1), `prompt>ab${CURSOR_MARKER}`]); tui.requestRender(true, { allowUnknownViewportMutation: true }); await settle(term); - expect(visible(term).map(l => l.trim())).toEqual(["line-24", "line-25", "line-26", "line-27", "line-28", "prompt>ab"]); + expect(visible(term).map(l => l.trim())).toEqual([ + "line-24", + "line-25", + "line-26", + "line-27", + "line-28", + "prompt>ab", + ]); expect(term.getCursor()).toEqual({ row: 5, col: "prompt>ab".length }); } finally { Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); @@ -2146,7 +2174,14 @@ describe("TUI terminal-state regressions", () => { component.setLines([...initial.slice(0, 5), ...initial.slice(6)]); tui.requestRender(); await settle(term); - expect(visible(term).map(l => l.trim())).toEqual(["line-24", "line-25", "line-26", "line-27", "line-28", "line-29"]); + expect(visible(term).map(l => l.trim())).toEqual([ + "line-24", + "line-25", + "line-26", + "line-27", + "line-28", + "line-29", + ]); } finally { tui.stop(); }