diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 604ffc3..0da07b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: cache: npm - run: npm ci - name: Native renderer tests on this OS - run: npx electron --import tsx --test tests/pdf-native-render.test.ts tests/svg-native-render.test.ts tests/browser-service.test.ts + run: npx electron --import tsx --test tests/pdf-native-render.test.ts tests/pdf-ops.test.ts tests/svg-native-render.test.ts tests/browser-service.test.ts env: NODE_ENV: test ELECTRON_RUN_AS_NODE: "1" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d145c65..3c31f35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,7 +115,7 @@ jobs: cache: npm - run: npm ci - name: Native renderer tests on this OS - run: npx electron --import tsx --test tests/pdf-native-render.test.ts tests/svg-native-render.test.ts tests/browser-service.test.ts + run: npx electron --import tsx --test tests/pdf-native-render.test.ts tests/pdf-ops.test.ts tests/svg-native-render.test.ts tests/browser-service.test.ts env: NODE_ENV: test ELECTRON_RUN_AS_NODE: "1" diff --git a/AGENTS.md b/AGENTS.md index b79af9b..5cc36a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,24 +8,7 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex - - -# Workflow: you are one parallel development stream - -This repo is developed in parallel using git worktrees. You are one stream; the master session merges. - -- **Read `SCOPE.md` at the repo root first** — it holds your mission, your dev-server port, your owned files, and your done-protocol. It is untracked (never commit it). -- **Stay in your lane.** Only touch files SCOPE.md lists as yours. If work outside your lane is needed, write it up in your final summary instead of doing it. -- **Contracts are split by domain** (`src/core/contracts/{plan,graph,chat,jobs,probe,models}.ts`): each stream owns its domain's contract file outright. Schema changes in a domain you don't own go through the master session — never change another domain's contract in your branch. -- **Gates before every commit:** `npm run typecheck` and `npm test` and `npm run lint` all pass; `npm run build` before declaring done. -- **Dev server:** always `PORT= npm run dev` — port 3000 belongs to the master tree and Next refuses to start a second server on it. -- `.env.local` is a symlink to the master tree's copy (secrets only — never edit it). Models + reasoning budgets live in `config.ts` MODELS. -- Your `data/` is disposable scratch (chat records + graph ledgers). The master tree holds the real data. Never copy data back into the master tree; a one-way throwaway copy of master's `data/graph` + `data/chats` into your worktree for testing is fine. -- **Don't merge yourself.** When your wave is done: stop, summarize (what changed, what you deliberately didn't touch, open questions), and wait for the master to merge and rebase you. - - - -# No guessing (master directive — applies to every stream) +# No guessing All design authority belongs to the human. **Never guess a design or implementation decision.** If anything is ambiguous — naming, behavior, data shapes, UX, defaults, scope boundaries, prompt wording — STOP and ask with the ask-user-question tool before proceeding. If you are "a little unsure," you are unsure: ask. A wrong guess is far more expensive than a question. Ask early, ask specific, one question per call. diff --git a/src/agent/subagents/headless.ts b/src/agent/subagents/headless.ts index 296b01a..be90352 100644 --- a/src/agent/subagents/headless.ts +++ b/src/agent/subagents/headless.ts @@ -3,6 +3,7 @@ import { createRequire } from "node:module"; import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { standardFontsDir } from "./pdf-ops"; /** * Verification engines for sub-agents: demos use the app's own Electron @@ -483,9 +484,7 @@ async function renderPdfPages( // Standard-font data as a directory path (live-smoke finding 2026-09-19: // without it pdfjs renders non-embedded standard fonts via fallback); // verbosity 0 = errors only (JBIG2 noise on scanned PDFs). - const standardFontDataUrl = - path.join(PATHS_nodeModules(), "pdfjs-dist", "standard_fonts") + path.sep; - const loadingTask = pdfjs.getDocument({ data, standardFontDataUrl, verbosity: 0 }); + const loadingTask = pdfjs.getDocument({ data, standardFontDataUrl: standardFontsDir(), verbosity: 0 }); const doc = await loadingTask.promise; try { const out: RenderedPdfPage[] = []; @@ -515,12 +514,6 @@ async function renderPdfPages( } } -/** node_modules — resolved from this module's location. */ -function PATHS_nodeModules(): string { - // import.meta.url = .../src/agent/subagents/headless.ts → three up + node_modules - return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "node_modules"); -} - // ─── the real API ──────────────────────────────────────────────────────────── export const realHeadless: HeadlessApi = { diff --git a/src/agent/subagents/pdf-ops.ts b/src/agent/subagents/pdf-ops.ts index 0c2c25f..1bb3ac6 100644 --- a/src/agent/subagents/pdf-ops.ts +++ b/src/agent/subagents/pdf-ops.ts @@ -8,8 +8,8 @@ import { fileURLToPath } from "node:url"; * pdf_search / pdf_render), on the user-chosen hybrid stack: * * - TEXT (info / extract / search): pdfjs-dist — pure JS, no native deps. - * - RENDER: the shared headless-Chrome infra (subagents/headless.ts, - * puppeteer + system Chrome) screenshots pdfium-rendered pages. + * - RENDER: subagents/headless.ts uses the same pdfjs engine with + * @napi-rs/canvas (Skia), in-process — no browser needed. * * Doctrine (the skill's reading strategy, mirrored in the sub-agent's system * prompt): triage with pdf_info FIRST, then size-based strategy — short PDFs @@ -45,10 +45,11 @@ async function loadPdfjs() { /** Standard-font data dir (live-smoke finding 2026-09-19: without it pdfjs * warns and falls back on non-embedded standard fonts — Helvetica, Times…). - * The node side takes a plain filesystem path. Resolved from this module's - * location, the headless.ts PATHS_nodeModules idiom. */ + * The node side takes a plain filesystem path, shared by text and rendering. + * pdfjs checks endsWith("/") even on Windows: use a literal trailing slash, + * NOT path.sep ("\\" on Windows). Node fs accepts this path on every OS. */ export function standardFontsDir(): string { - return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "node_modules", "pdfjs-dist", "standard_fonts") + path.sep; + return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "node_modules", "pdfjs-dist", "standard_fonts") + "/"; } async function openDocument(filePath: string) { diff --git a/tests/chat-flow.test.ts b/tests/chat-flow.test.ts index a638d6e..7837420 100644 --- a/tests/chat-flow.test.ts +++ b/tests/chat-flow.test.ts @@ -1598,7 +1598,8 @@ describe("duplicate provider tool-call ids (the Lacan fix)", () => { () => fauxAssistantMessage([fauxText("Right — and that is where Lacan begins.")]), ]); - const chat = chatStore.createChat("Lacan: duplicate call_0 turns"); + const chat = createConfiguredChat("Lacan: duplicate call_0 turns"); + createdChats.push(chat.id); // Turn 1: the ask question. await drainTurn(post(chat.id, { type: "text", text: "teach me Lacan" }), agentFactory(engine)); let saved = chatStore.getChat(chat.id)!; diff --git a/tests/keyboard-toggle-sidebar.test.ts b/tests/keyboard-toggle-sidebar.test.ts index fe48756..a08c6e1 100644 --- a/tests/keyboard-toggle-sidebar.test.ts +++ b/tests/keyboard-toggle-sidebar.test.ts @@ -4,6 +4,7 @@ import { actionForBinding, bindingForAction, formatBinding, + IS_MAC, kbdEventForAction, KEYBOARD_ACTIONS, } from "../src/core/keyboard.ts"; @@ -40,7 +41,7 @@ describe("the ⌘B chain — keypress → binding → action → event", () => { it("mod+b is the toggle-sidebar action", () => { assert.equal(actionForBinding("mod+b"), "toggle-sidebar"); assert.equal(bindingForAction("toggle-sidebar"), "mod+b"); - assert.equal(formatBinding("mod+b"), "⌘B"); + assert.equal(formatBinding("mod+b"), IS_MAC ? "⌘B" : "Ctrl+B"); }); it("the event the chain ends in is the one the sidebar answers on", () => { @@ -51,9 +52,9 @@ describe("the ⌘B chain — keypress → binding → action → event", () => { }); describe("the thinking pill suffix — ⇧⇥ via the registry", () => { - it("shift+tab formats as ⇧⇥ and maps to cycle-thinking", () => { + it("shift+tab uses the platform's modifier label and maps to cycle-thinking", () => { assert.equal(actionForBinding("shift+tab"), "cycle-thinking"); - assert.equal(formatBinding("shift+tab"), "⇧⇥"); + assert.equal(formatBinding("shift+tab"), IS_MAC ? "⇧⇥" : "Shift+⇥"); }); }); diff --git a/tests/pdf-native-render.test.ts b/tests/pdf-native-render.test.ts index a486059..017aa8c 100644 --- a/tests/pdf-native-render.test.ts +++ b/tests/pdf-native-render.test.ts @@ -7,9 +7,11 @@ * this process). */ import assert from "node:assert/strict"; +import * as fs from "node:fs"; import * as path from "node:path"; import { describe, it } from "node:test"; import { realHeadless } from "../src/agent/subagents/headless.ts"; +import { standardFontsDir } from "../src/agent/subagents/pdf-ops.ts"; const FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf"); @@ -30,6 +32,12 @@ function pngFacts(buf: Buffer): { w: number; h: number; idats: number } { } describe("native pdf_render (pdfjs paints, @napi-rs/canvas hosts)", () => { + it("the shared font directory ends in pdfjs's required slash on every OS", () => { + const fonts = standardFontsDir(); + assert.ok(fonts.endsWith("/"), "pdfjs rejects a trailing Windows backslash"); + assert.ok(fs.statSync(fonts).isDirectory(), "the filesystem accepts the shared font path"); + }); + it("renders fixture pages as canonical single-IDAT PNGs", async () => { const pages = await realHeadless.renderPdfPages(FIXTURE, [1, 3], { dpi: 150 }); assert.equal(pages.length, 2, "requested pages 1 and 3"); diff --git a/tests/tooltip-mechanism.test.tsx b/tests/tooltip-mechanism.test.tsx index 0e838b0..653cfe6 100644 --- a/tests/tooltip-mechanism.test.tsx +++ b/tests/tooltip-mechanism.test.tsx @@ -3,10 +3,9 @@ * icon/pill controls ride the styled Radix tooltip via Hint, the shortcut * appended as an em-dash suffix off the LIVE registry (a rebind changes * the suffix); truncation-reveals keep native title=; the copy carries no - * internal vocabulary. Node has no navigator → IS_MAC is false → the mod - * glyph renders "Ctrl+" here; the composition logic is the pin, not the - * platform glyph. (Node 26 ships navigator.platform — on this Mac the real - * ⌘/⇧ glyphs render, so they're pinned as-is.) + * internal vocabulary. Node 26 exposes the host platform through navigator, + * so expectations cover Mac glyphs AND Ctrl+/Shift+ elsewhere. Composition + * is the pin; running on a Mac must not be a test prerequisite. */ import React from "react"; @@ -19,12 +18,13 @@ import { renderToStaticMarkup } from "react-dom/server"; import { Hint, hintText } from "../components/ui/hint.tsx"; import { TOOLTIP_DELAY_MS } from "../components/ui/tooltip.tsx"; import { AUTO_UPDATE_HINT } from "../components/settings/settings-dialog.tsx"; +import { IS_MAC } from "../src/core/keyboard.ts"; describe("hintText — the parenthesized composition", () => { it("a registry action appends the LIVE binding (override-aware source)", () => { - assert.equal(hintText("Dictate", "toggle-dictation"), "Dictate (⌘D)"); - assert.equal(hintText("Settings", "open-settings"), "Settings (⌘,)"); - assert.equal(hintText("Search sessions", "sessions-search"), "Search sessions (⌘⇧F)"); + assert.equal(hintText("Dictate", "toggle-dictation"), IS_MAC ? "Dictate (⌘D)" : "Dictate (Ctrl+D)"); + assert.equal(hintText("Settings", "open-settings"), IS_MAC ? "Settings (⌘,)" : "Settings (Ctrl+,)"); + assert.equal(hintText("Search sessions", "sessions-search"), IS_MAC ? "Search sessions (⌘⇧F)" : "Search sessions (Ctrl+Shift+F)"); }); it("surface-owned keys ride the binding prop (no registry entry)", () => {