Merge model-visible image processing and desktop file-path attachments

This commit is contained in:
amosblomqvist
2026-10-28 19:54:32 +02:00
58 changed files with 4374 additions and 119 deletions
+3 -2
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { ConfidenceRail } from "@/components/confidence-rail"; import { ConfidenceRail } from "@/components/confidence-rail";
import { GlyphMark } from "@/components/ui/glyph-mark"; import { GlyphMark } from "@/components/ui/glyph-mark";
import { ThinkingBlock } from "@/components/thinking-block"; import { ThinkingBlock } from "@/components/thinking-block";
import { PathText } from "@/components/file-path-chip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ToolContentResponse } from "@/components/ai-elements/message"; import { ToolContentResponse } from "@/components/ai-elements/message";
import type { QuestionAnswerData } from "@/src/core"; import type { QuestionAnswerData } from "@/src/core";
@@ -153,13 +154,13 @@ export function AnsweredCard({ answer: savedAnswer, input }: { answer: QuestionA
data-testid="answered-other" data-testid="answered-other"
className="flex items-center gap-2 rounded-lg border border-primary bg-accent px-3 py-1.5" className="flex items-center gap-2 rounded-lg border border-primary bg-accent px-3 py-1.5"
> >
<span>“{answer.other}”</span> <span>“<PathText text={answer.other} />”</span>
</div> </div>
)} )}
</div> </div>
)} )}
{answer.text && <div className="lh-prose mt-2 whitespace-pre-wrap">“{answer.text}”</div>} {answer.text && <div className="lh-prose mt-2 whitespace-pre-wrap">“<PathText text={answer.text} />”</div>}
{/* The confidence record (owner round 6): the slider's rail, disc {/* The confidence record (owner round 6): the slider's rail, disc
removed — the missing handle itself says "this is a record, not a removed — the missing handle itself says "this is a record, not a
+7 -6
View File
@@ -14,6 +14,7 @@ import {
ConversationScrollButton, ConversationScrollButton,
} from "@/components/ai-elements/conversation"; } from "@/components/ai-elements/conversation";
import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message"; import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message";
import { PathText } from "@/components/file-path-chip";
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning"; import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning";
import { Shimmer } from "@/components/ai-elements/shimmer"; import { Shimmer } from "@/components/ai-elements/shimmer";
import { BrailleSpinner } from "@/components/braille-spinner"; import { BrailleSpinner } from "@/components/braille-spinner";
@@ -687,10 +688,10 @@ export function Chat({
const cycleChatFields = useCallback((): readonly HTMLElement[] => { const cycleChatFields = useCallback((): readonly HTMLElement[] => {
const pick = (sel: string) => Array.from(document.querySelectorAll<HTMLElement>(sel)); const pick = (sel: string) => Array.from(document.querySelectorAll<HTMLElement>(sel));
return [ return [
...pick('[data-testid="reading-thinking-field"] textarea'), ...pick('[data-testid="reading-thinking-field"] [data-file-path-input]'),
...pick('[data-testid="question-dock"] textarea'), ...pick('[data-testid="question-dock"] [data-file-path-input]'),
...pick('[data-testid="practice-dock"] textarea'), ...pick('[data-testid="practice-dock"] [data-file-path-input]'),
...pick('[data-testid="composer"] textarea'), ...pick('[data-testid="composer"] [data-file-path-input]'),
]; ];
}, []); }, []);
useFieldCycle(cycleChatFields); useFieldCycle(cycleChatFields);
@@ -705,7 +706,7 @@ export function Chat({
* composer: with a question pending the Composer is unmounted, the ref * composer: with a question pending the Composer is unmounted, the ref
* is null, and the thinking field is left alone by design. */ * is null, and the thinking field is left alone by design. */
const dictationWasActiveRef = useRef(false); const dictationWasActiveRef = useRef(false);
const composerInputRef = useRef<HTMLTextAreaElement>(null); const composerInputRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (dictation.active) { if (dictation.active) {
dictationWasActiveRef.current = true; dictationWasActiveRef.current = true;
@@ -1507,7 +1508,7 @@ export function Chat({
rail as the thinking rows — one quote geometry. */} rail as the thinking rows — one quote geometry. */}
<div className="font-medium uppercase tracking-wide text-muted-foreground">You</div> <div className="font-medium uppercase tracking-wide text-muted-foreground">You</div>
<div className="lh-prose whitespace-pre-wrap text-foreground"> <div className="lh-prose whitespace-pre-wrap text-foreground">
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")} <PathText text={m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")} />
</div> </div>
</div> </div>
) : ( ) : (
+5 -4
View File
@@ -3,6 +3,7 @@
import { ArrowRightIcon, CornerDownLeftIcon, SquareIcon } from "lucide-react"; import { ArrowRightIcon, CornerDownLeftIcon, SquareIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Hint } from "@/components/ui/hint"; import { Hint } from "@/components/ui/hint";
import { PathInput } from "@/components/path-input";
/** /**
* The chat composer — content row ONLY (the persistent rounded shell lives in * The chat composer — content row ONLY (the persistent rounded shell lives in
@@ -59,7 +60,7 @@ export function Composer({
autoFocus?: boolean; autoFocus?: boolean;
/** The page's handle to the textarea — the dictation-end focus (owner, /** The page's handle to the textarea — the dictation-end focus (owner,
* 2026-10-01): a run ending hands the cursor to the bare composer. */ * 2026-10-01): a run ending hands the cursor to the bare composer. */
inputRef?: React.RefObject<HTMLTextAreaElement | null>; inputRef?: React.RefObject<HTMLDivElement | null>;
/** Escape while a dictation runs stops it (STT wave, 2026-09-17). No-op /** Escape while a dictation runs stops it (STT wave, 2026-09-17). No-op
* when absent — Esc otherwise means nothing in the composer. */ * when absent — Esc otherwise means nothing in the composer. */
onEscape?: () => void; onEscape?: () => void;
@@ -90,10 +91,11 @@ export function Composer({
act(); act();
}} }}
> >
<textarea <PathInput
ref={inputRef} ref={inputRef}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onValueChange={onChange}
defaultDropTarget
onKeyDown={(e) => { onKeyDown={(e) => {
// Esc ends dictation before anything else Esc might someday mean // Esc ends dictation before anything else Esc might someday mean
// here (the dictate decisions: click-again or Esc to stop). // here (the dictate decisions: click-again or Esc to stop).
@@ -115,7 +117,6 @@ export function Composer({
}} }}
placeholder={placeholder} placeholder={placeholder}
autoFocus={autoFocus} autoFocus={autoFocus}
rows={1}
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground" className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
/> />
{/* The button follows the text (owner report 2026-10-03): while {/* The button follows the text (owner report 2026-10-03): while
+18
View File
@@ -199,6 +199,17 @@ export function rmsToMeter(rms: number): number {
* window, one pending question, one target at a time. * window, one pending question, one target at a time.
*/ */
let questionTarget: ((text: string) => void) | null = null; let questionTarget: ((text: string) => void) | null = null;
let composerTarget: ((text: string) => void) | null = null;
/** File references use the very same default destination as dictation,
* including claims whose dock is currently collapsed. A focused editor can
* insert at its caret instead; callers use this only as the fallback. */
export function appendToPrimaryInput(text: string): boolean {
const target = questionTarget ?? composerTarget;
if (!target) return false;
target(text);
return true;
}
/* The finisher registry: submit paths FAR from the hook's owner (the /* The finisher registry: submit paths FAR from the hook's owner (the
* pending question's dock) need finish() without a prop bridge. Same * pending question's dock) need finish() without a prop bridge. Same
@@ -280,6 +291,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
* retarget (composer↔question) is honored on the very next final. */ * retarget (composer↔question) is honored on the very next final. */
const appendRef = useRef(append); const appendRef = useRef(append);
appendRef.current = append; appendRef.current = append;
useEffect(() => {
const target = (text: string) => appendRef.current(text);
composerTarget = target;
return () => {
if (composerTarget === target) composerTarget = null;
};
}, []);
// Live machinery (non-render values). The MediaStream tracks stop and // Live machinery (non-render values). The MediaStream tracks stop and
// the per-run nodes disconnect on teardown; the socket is recreated per // the per-run nodes disconnect on teardown; the socket is recreated per
+62
View File
@@ -0,0 +1,62 @@
import React, { Fragment } from "react";
import {
File,
FileArchive,
FileAudio,
FileCode,
FileImage,
FileSpreadsheet,
FileText,
FileVideo,
} from "lucide-react";
import { filePathLabel, parseFilePaths } from "@/src/core";
function fileIcon(label: string) {
const extension = label.split(".").at(-1)?.toLowerCase();
if (/^(png|jpe?g|gif|webp|svg|bmp|tiff?|avif|heic)$/.test(extension ?? "")) return FileImage;
if (/^(mp3|wav|ogg|flac|m4a|aac)$/.test(extension ?? "")) return FileAudio;
if (/^(mp4|mov|webm|mkv|avi)$/.test(extension ?? "")) return FileVideo;
if (/^(zip|gz|tar|bz2|xz|7z|rar)$/.test(extension ?? "")) return FileArchive;
if (/^(csv|tsv|xlsx?|ods)$/.test(extension ?? "")) return FileSpreadsheet;
if (/^(js|jsx|ts|tsx|py|rb|rs|go|c|cpp|h|java|json|html|css|sh|yaml|yml)$/.test(extension ?? "")) return FileCode;
if (/^(pdf|txt|md|mdx|rtf|docx?|odt|tex|epub)$/.test(extension ?? "")) return FileText;
return File;
}
export interface FilePathChipProps {
path: string;
/** Editors can make the chip atomic; transcript renderers can omit this. */
contentEditable?: false;
className?: string;
}
/** Pure presentation: no links, open actions, HTML injection or path probing. */
export function FilePathChip({ path, contentEditable, className }: FilePathChipProps) {
const label = filePathLabel(path);
const Icon = fileIcon(label);
return (
<span
role="img"
aria-label={path}
title={path}
contentEditable={contentEditable}
className={`inline-flex max-w-full items-center gap-1 rounded border bg-muted/50 px-1.5 py-0.5 align-middle text-sm${className ? ` ${className}` : ""}`}
>
<Icon aria-hidden="true" className="size-3.5 shrink-0" />
<span className="min-w-0 truncate">{label}</span>
</span>
);
}
/** Ordinary learner text stays ordinary text; only quoted paths become chips. */
export function PathText({ text }: { text: string }) {
return (
<>
{parseFilePaths(text).map((part) => (
<Fragment key={part.start}>
{part.kind === "path" ? <FilePathChip path={part.path} /> : part.text}
</Fragment>
))}
</>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { FileSymlinkIcon, XIcon } from "lucide-react";
import { quoteFilePath } from "@/src/core";
import { appendToPrimaryInput } from "@/components/dictation";
import { INSERT_FILE_PATHS_EVENT } from "@/components/path-input";
const BROWSER_NOTICE = "Local file paths can only be attached in the desktop app. You can paste a full path instead.";
/** The content area only: the sidebar is deliberately outside this zone.
* Existing files stay where they are; only quoted path strings enter drafts. */
export function FilePathDropZone({ children, className }: { children: React.ReactNode; className?: string }) {
const root = useRef<HTMLElement>(null);
const depth = useRef(0);
const [dragging, setDragging] = useState(false);
const [notice, setNotice] = useState("");
const hasFiles = (event: React.DragEvent) => Array.from(event.dataTransfer.types).includes("Files");
const clearDrag = () => { depth.current = 0; setDragging(false); };
useEffect(() => {
// The sidebar remains a non-target, but dropping a file there must not
// navigate the app away. No indicator or attachment is produced there.
const preventOutsideNavigation = (event: DragEvent) => {
if (Array.from(event.dataTransfer?.types ?? []).includes("Files")
&& event.target instanceof Node && !root.current?.contains(event.target)) event.preventDefault();
};
window.addEventListener("dragover", preventOutsideNavigation);
window.addEventListener("drop", preventOutsideNavigation);
return () => {
window.removeEventListener("dragover", preventOutsideNavigation);
window.removeEventListener("drop", preventOutsideNavigation);
};
}, []);
return (
<main
ref={root}
className={className}
data-testid="file-path-drop-zone"
onDragEnter={(event) => {
if (!hasFiles(event)) return;
event.preventDefault();
depth.current++;
setDragging(true);
setNotice("");
}}
onDragOver={(event) => {
if (!hasFiles(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(event) => {
if (!hasFiles(event)) return;
depth.current = Math.max(0, depth.current - 1);
if (depth.current === 0) setDragging(false);
}}
onDragEnd={clearDrag}
onDrop={(event) => {
if (!hasFiles(event)) return;
event.preventDefault();
event.stopPropagation();
clearDrag();
const resolve = window.lh?.getPathForFile;
if (!resolve) { setNotice(BROWSER_NOTICE); return; }
// Directories are not file references. This check uses drag metadata,
// never a server request or a new filesystem capability.
const entries = Array.from(event.dataTransfer.items).filter((item) => item.kind === "file");
const paths: string[] = [];
let skipped = 0;
for (const [index, file] of Array.from(event.dataTransfer.files).entries()) {
try {
if (entries[index]?.webkitGetAsEntry?.()?.isDirectory) { skipped++; continue; }
const path = resolve(file);
quoteFilePath(path); // Same rooted-path validity rule as typed chips.
paths.push(path);
} catch { skipped++; }
}
if (paths.length === 0) {
setNotice("No local file paths were available. Drop existing files from your computer.");
return;
}
const focused = document.activeElement?.closest<HTMLElement>("[data-file-path-input]");
if (focused && event.currentTarget.contains(focused)) {
focused.dispatchEvent(new CustomEvent(INSERT_FILE_PATHS_EVENT, { detail: paths }));
} else if (!appendToPrimaryInput(paths.map(quoteFilePath).join(" "))) {
setNotice("Open a chat to attach file paths.");
return;
}
setNotice(skipped ? "Some dropped items had no available file path." : "");
}}
>
{children}
{dragging && (
<div data-testid="file-path-drop-indicator" className="pointer-events-none absolute inset-2 z-50 flex items-center justify-center rounded-xl border-2 border-dashed border-primary bg-background/85 p-6 text-center backdrop-blur-sm">
<div className="flex max-w-md flex-col items-center gap-3">
<FileSymlinkIcon className="size-8 text-primary" aria-hidden />
<span className="font-ui text-sm">{window.lh?.getPathForFile ? "Drop to attach file paths" : BROWSER_NOTICE}</span>
</div>
</div>
)}
{notice && <div role="status" className="absolute bottom-2 left-1/2 z-50 flex max-w-[90%] -translate-x-1/2 items-center gap-2 rounded-md border bg-card px-3 py-2 font-ui text-xs shadow-sm">
<span>{notice}</span>
<button type="button" aria-label="Dismiss attachment notice" onClick={() => setNotice("")} className="shrink-0 rounded-sm p-1 hover:bg-accent"><XIcon className="size-3" aria-hidden /></button>
</div>}
</main>
);
}
+7 -6
View File
@@ -13,6 +13,7 @@ import { useFieldCycle } from "@/components/field-cycle";
import { SidebarToggle } from "@/components/sidebar"; import { SidebarToggle } from "@/components/sidebar";
import { StatsBar } from "@/components/stats-bar"; import { StatsBar } from "@/components/stats-bar";
import { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt"; import { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt";
import { PathInput } from "@/components/path-input";
import { SessionStarter } from "@/components/start-session"; import { SessionStarter } from "@/components/start-session";
import type { TurnUsage } from "@/src/core"; import type { TurnUsage } from "@/src/core";
@@ -64,7 +65,7 @@ export function Home({
/* Run end focuses the topic input (owner, 2026-10-01): the hands are on /* Run end focuses the topic input (owner, 2026-10-01): the hands are on
* the keyboard from the stop. Falling edge only; this surface's only * the keyboard from the stop. Falling edge only; this surface's only
* dictation target IS this textarea, so no case-split is needed. */ * dictation target IS this textarea, so no case-split is needed. */
const topicInputRef = useRef<HTMLTextAreaElement>(null); const topicInputRef = useRef<HTMLDivElement>(null);
// Tab navigates INTO the topic composer (owner, keyboard round // Tab navigates INTO the topic composer (owner, keyboard round
// 2026-10-06): the only text field on this surface — and home's // 2026-10-06): the only text field on this surface — and home's
// composer is its OWN textarea, not the shared Composer component, // composer is its OWN textarea, not the shared Composer component,
@@ -248,13 +249,14 @@ export function Home({
void start(); void start();
}} }}
> >
<textarea <PathInput
ref={topicInputRef} ref={topicInputRef}
value={topic} value={topic}
onChange={(e) => { onValueChange={(value) => {
topicRef.current = e.target.value; topicRef.current = value;
setTopic(e.target.value); setTopic(value);
}} }}
defaultDropTarget
onKeyDown={(e) => { onKeyDown={(e) => {
// Esc ends dictation (the dictate decisions: click-again or // Esc ends dictation (the dictate decisions: click-again or
// Esc) — stopPropagation claims it (keyboard wave A) so // Esc) — stopPropagation claims it (keyboard wave A) so
@@ -272,7 +274,6 @@ export function Home({
}} }}
placeholder={mode === "practice" ? "What do you want to practice?" : "What do you want to learn?"} placeholder={mode === "practice" ? "What do you want to practice?" : "What do you want to learn?"}
autoFocus autoFocus
rows={1}
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground" className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
/> />
{/* No tooltip on the start square — the ↵ glyph is the {/* No tooltip on the start square — the ↵ glyph is the
+2 -2
View File
@@ -114,7 +114,7 @@ export function DraftShell({
// Tab navigates INTO the composer (owner, keyboard round 2026-10-06): // Tab navigates INTO the composer (owner, keyboard round 2026-10-06):
// the only text field on this surface — the cycle's whole chain. // the only text field on this surface — the cycle's whole chain.
const cycleComposer = useCallback((): readonly HTMLElement[] => const cycleComposer = useCallback((): readonly HTMLElement[] =>
Array.from(document.querySelectorAll<HTMLElement>('[data-testid="composer"] textarea')), Array.from(document.querySelectorAll<HTMLElement>('[data-testid="composer"] [data-file-path-input]')),
[]); []);
useFieldCycle(cycleComposer); useFieldCycle(cycleComposer);
const dictation = useDictation({ const dictation = useDictation({
@@ -138,7 +138,7 @@ export function DraftShell({
}; };
/* Run end focuses the composer (owner, 2026-10-01): the hands are on /* Run end focuses the composer (owner, 2026-10-01): the hands are on
* the keyboard from the stop — falling edge of `active` only. */ * the keyboard from the stop — falling edge of `active` only. */
const composerInputRef = useRef<HTMLTextAreaElement>(null); const composerInputRef = useRef<HTMLDivElement>(null);
const dictationWasActiveRef = useRef(false); const dictationWasActiveRef = useRef(false);
useEffect(() => { useEffect(() => {
if (dictation.active) { if (dictation.active) {
+257
View File
@@ -0,0 +1,257 @@
import { parseFilePaths, quoteFilePath } from "@/src/core";
/** Tokens store their complete source, not their abbreviated visual label. */
export const PATH_SOURCE_ATTRIBUTE = "data-file-path-source";
/** Presentation-only line box for the caret after a final model newline. */
export const PATH_CARET_ATTRIBUTE = "data-file-path-caret";
export type TextSelection = { anchor: number; focus: number };
export type DOMPoint = { node: Node; offset: number };
type Segment = { text: string; start: DOMPoint; end: DOMPoint; node?: Text; token?: Element };
function childIndex(node: Node): number {
return Array.prototype.indexOf.call(node.parentNode!.childNodes, node);
}
/** Browser editing can leave BR/block nodes; never use innerText (layout/whitespace dependent). */
function segments(root: HTMLElement): Segment[] {
const result: Segment[] = [];
function walk(node: Node) {
if (node.nodeType === 3) {
const text = node as Text;
result.push({ text: text.data, start: { node, offset: 0 }, end: { node, offset: text.length }, node: text });
return;
}
if (node.nodeType !== 1) return;
const element = node as Element;
if (element.hasAttribute(PATH_CARET_ATTRIBUTE)) return;
const raw = element.getAttribute(PATH_SOURCE_ATTRIBUTE);
if (raw !== null) {
const index = childIndex(node);
result.push({ text: raw, start: { node: node.parentNode!, offset: index }, end: { node: node.parentNode!, offset: index + 1 }, token: element });
return;
}
if (element.tagName === "BR") {
// Chromium's lone BR is an empty editor/paragraph placeholder.
if (node.parentNode?.childNodes.length === 1) return;
const index = childIndex(node);
result.push({ text: "\n", start: { node: node.parentNode!, offset: index }, end: { node: node.parentNode!, offset: index + 1 } });
return;
}
Array.from(node.childNodes).forEach((child, index) => {
const block = child.nodeType === 1 && /^(DIV|P)$/.test((child as Element).tagName);
const previous = node.childNodes[index - 1];
const previousBlock = previous?.nodeType === 1 && /^(DIV|P)$/.test((previous as Element).tagName);
if (index > 0 && (block || previousBlock)) {
result.push({ text: "\n", start: { node, offset: index }, end: { node, offset: index } });
}
walk(child);
});
}
walk(root);
return result;
}
export function readPathInput(root: HTMLElement): string {
return segments(root).map((part) => part.text).join("");
}
/** Map a logical UTF-16 offset to a DOM boundary, snapping a chip interior atomically. */
export function pathInputPoint(root: HTMLElement, offset: number): DOMPoint {
const parts = segments(root);
const trailing = root.lastChild;
if (trailing?.nodeType === 1 && (trailing as Element).hasAttribute(PATH_CARET_ATTRIBUTE)
&& offset >= parts.reduce((length, part) => length + part.text.length, 0)) {
// A text-node endpoint after its final newline has no line box in Chromium.
// The boundary immediately before this BR is on the visible final line.
return { node: root, offset: childIndex(trailing) };
}
let remaining = Math.max(0, offset);
for (const part of parts) {
if (remaining <= part.text.length) {
if (part.node) return { node: part.node, offset: remaining };
return remaining <= part.text.length / 2 ? part.start : part.end;
}
remaining -= part.text.length;
}
return { node: root, offset: root.childNodes.length };
}
/** Works without Range: useful in DOM test environments as well as real Selection. */
export function pathInputOffset(root: HTMLElement, node: Node, offset: number): number | null {
if (node !== root && !root.contains(node)) return null;
let total = 0;
for (const part of segments(root)) {
if (part.node === node) return total + Math.min(Math.max(0, offset), part.text.length);
if (part.token?.contains(node)) {
// A mouse/programmatic selection can end inside a noneditable label.
// Keep distinct interior endpoints so atomicPathRange expands copy/cut
// to the complete source instead of leaking the abbreviated filename.
let visualOffset = node.nodeType === 3 ? offset : Array.from(node.childNodes).slice(0, offset).reduce((length, child) => length + (child.textContent?.length ?? 0), 0);
let cursor = node;
while (cursor !== part.token) {
let sibling = cursor.previousSibling;
while (sibling) { visualOffset += sibling.textContent?.length ?? 0; sibling = sibling.previousSibling; }
cursor = cursor.parentNode!;
}
const visualLength = part.token.textContent?.length ?? 0;
return total + (visualLength ? Math.round(Math.min(visualLength, visualOffset) / visualLength * part.text.length) : 0);
}
if (part.start.node === node && part.start.offset >= offset) return total;
if (part.end.node === node && part.end.offset <= offset) {
total += part.text.length;
continue;
}
// A boundary in an ancestor is before/after every descendant segment.
let ancestor: Node = part.node ?? part.token ?? part.start.node;
while (ancestor.parentNode && ancestor.parentNode !== node) ancestor = ancestor.parentNode;
if (ancestor.parentNode === node && childIndex(ancestor) >= offset) return total;
total += part.text.length;
}
return total;
}
/** Validate a browser beforeinput StaticRange before projecting its exact endpoints. */
export function pathInputStaticRange(root: HTMLElement, range: Pick<StaticRange, "startContainer" | "startOffset" | "endContainer" | "endOffset">): TextSelection | null {
function valid(node: Node, offset: number) {
return (node === root || root.contains(node)) && Number.isInteger(offset) && offset >= 0
&& offset <= (node.nodeType === 3 ? (node as Text).length : node.childNodes.length);
}
if (!valid(range.startContainer, range.startOffset) || !valid(range.endContainer, range.endOffset)) return null;
const anchor = pathInputOffset(root, range.startContainer, range.startOffset);
const focus = pathInputOffset(root, range.endContainer, range.endOffset);
return anchor === null || focus === null || anchor >= focus ? null : { anchor, focus };
}
export function readPathSelection(root: HTMLElement): TextSelection | null {
const selection = root.ownerDocument.getSelection?.();
if (!selection?.anchorNode || !selection.focusNode) return null;
const anchor = pathInputOffset(root, selection.anchorNode, selection.anchorOffset);
const focus = pathInputOffset(root, selection.focusNode, selection.focusOffset);
return anchor === null || focus === null ? null : { anchor, focus };
}
export function restorePathSelection(root: HTMLElement, selection: TextSelection): void {
const browserSelection = root.ownerDocument.getSelection?.();
if (!browserSelection) return;
const anchor = pathInputPoint(root, selection.anchor);
const focus = pathInputPoint(root, selection.focus);
browserSelection.setBaseAndExtent(anchor.node, anchor.offset, focus.node, focus.offset);
}
/** Returns portal hosts. React owns only their visual contents, never the editable tree. */
export function renderPathInput(root: HTMLElement, value: string): Array<{ host: HTMLElement; path: string }> {
const document = root.ownerDocument;
const fragment = document.createDocumentFragment();
const chips: Array<{ host: HTMLElement; path: string }> = [];
for (const part of parseFilePaths(value)) {
if (part.kind === "text") {
fragment.append(document.createTextNode(part.text));
} else {
const host = document.createElement("span");
host.contentEditable = "false";
host.setAttribute("contenteditable", "false");
host.setAttribute(PATH_SOURCE_ATTRIBUTE, part.text);
fragment.append(host);
chips.push({ host, path: part.path });
}
}
// Empty boundary text nodes allow a caret immediately before/after an atomic chip.
if (!fragment.firstChild || fragment.firstChild.nodeType !== 3) fragment.prepend(document.createTextNode(""));
if (fragment.lastChild?.nodeType !== 3) fragment.append(document.createTextNode(""));
if (value.endsWith("\n")) {
// pre-wrap alone collapses the final empty line. This BR supplies its
// height/caret without adding a second newline to the stored source.
const caret = document.createElement("br");
caret.setAttribute(PATH_CARET_ATTRIBUTE, "");
caret.setAttribute("aria-hidden", "true");
fragment.append(caret);
}
root.replaceChildren(fragment);
root.dataset.empty = value.length === 0 ? "true" : "false";
return chips;
}
export function clampPathSelection(value: string, selection: TextSelection): TextSelection {
const clamp = (offset: number) => Math.min(value.length, Math.max(0, offset));
return { anchor: clamp(selection.anchor), focus: clamp(selection.focus) };
}
/** A selection intersecting any part of a chip edits/copies that complete source. */
export function atomicPathRange(value: string, selection: TextSelection): { start: number; end: number } {
const clamped = clampPathSelection(value, selection);
let start = Math.min(clamped.anchor, clamped.focus);
let end = Math.max(clamped.anchor, clamped.focus);
for (const part of parseFilePaths(value)) {
if (part.kind !== "path") continue;
if (start > part.start && start < part.end) start = part.start;
if (end > part.start && end < part.end) end = part.end;
}
return { start, end };
}
export function replacePathSelection(value: string, selection: TextSelection, text: string) {
const { start, end } = atomicPathRange(value, selection);
return { value: value.slice(0, start) + text + value.slice(end), selection: { anchor: start + text.length, focus: start + text.length } };
}
export function insertFilePaths(value: string, selection: TextSelection, paths: string[]) {
const { start, end } = atomicPathRange(value, selection);
const text = paths.map(quoteFilePath).join(" ");
if (!text) return { value, selection };
const before = start > 0 && !/\s/.test(value[start - 1]!) ? " " : "";
const after = end < value.length && !/\s/.test(value[end]!) ? " " : "";
return replacePathSelection(value, selection, before + text + after);
}
export function adjacentPathRange(value: string, selection: TextSelection, direction: "backward" | "forward"): TextSelection | null {
if (selection.anchor !== selection.focus) return selection;
const caret = selection.focus;
for (const part of parseFilePaths(value)) {
if (part.kind === "path" && (direction === "backward" ? caret > part.start && caret <= part.end : caret >= part.start && caret < part.end)) {
return { anchor: part.start, focus: part.end };
}
}
return null;
}
export type PathEditSnapshot = { value: string; selection: TextSelection };
/** Match the text input value sanitization algorithm: remove CR/LF, not spaces. */
export function normalizePathInputValue(value: string, singleLine: boolean): string {
return singleLine ? value.replace(/[\r\n]/g, "") : value;
}
export function normalizePathEdit(snapshot: PathEditSnapshot, singleLine: boolean): PathEditSnapshot {
if (!singleLine) return snapshot;
const selection = clampPathSelection(snapshot.value, snapshot.selection);
return {
value: normalizePathInputValue(snapshot.value, true),
selection: {
anchor: normalizePathInputValue(snapshot.value.slice(0, selection.anchor), true).length,
focus: normalizePathInputValue(snapshot.value.slice(0, selection.focus), true).length,
},
};
}
/** Independent of the browser's DOM undo stack (normalization replaces native nodes). */
export class PathInputHistory {
private undoStack: PathEditSnapshot[] = [];
private redoStack: PathEditSnapshot[] = [];
push(snapshot: PathEditSnapshot) {
this.undoStack.push({ value: snapshot.value, selection: { ...snapshot.selection } });
this.redoStack = [];
}
reset() { this.undoStack = []; this.redoStack = []; }
undo(current: PathEditSnapshot): PathEditSnapshot | null {
const previous = this.undoStack.pop();
if (!previous) return null;
this.redoStack.push(current);
return previous;
}
redo(current: PathEditSnapshot): PathEditSnapshot | null {
const next = this.redoStack.pop();
if (!next) return null;
this.undoStack.push(current);
return next;
}
}
+303
View File
@@ -0,0 +1,303 @@
"use client";
import * as React from "react";
import { createPortal } from "react-dom";
import { FilePathChip } from "./file-path-chip";
import { cn } from "../lib/utils";
import {
adjacentPathRange, atomicPathRange, clampPathSelection, insertFilePaths,
normalizePathEdit, normalizePathInputValue, pathInputStaticRange, PathInputHistory, readPathInput, readPathSelection, renderPathInput,
replacePathSelection, restorePathSelection,
type PathEditSnapshot, type TextSelection,
} from "./path-input-dom";
export const INSERT_FILE_PATHS_EVENT = "lh:insert-file-paths";
export type PathInputProps = Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "dangerouslySetInnerHTML" | "defaultValue" | "onChange"> & {
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
autoFocus?: boolean;
defaultDropTarget?: boolean;
/** Native text-input semantics: strip CR/LF and leave Enter to the caller. */
singleLine?: boolean;
};
type ChipHost = { host: HTMLElement; path: string };
type Controller = {
external: (value: string) => void;
remember: () => void;
};
/**
* A controlled plaintext editor. Only the source string is state; chips are atomic
* views of it. React never reconciles the editable DOM after a native mutation.
*/
export const PathInput = React.forwardRef<HTMLDivElement, PathInputProps>(function PathInput({
value, onValueChange, placeholder, autoFocus, defaultDropTarget = false, singleLine = false, className,
onKeyDown, onFocus, onBlur, onInput, onPaste, onCopy, onCut,
onCompositionStart, onCompositionEnd, ...props
}, forwardedRef) {
const rootRef = React.useRef<HTMLDivElement>(null);
const controllerRef = React.useRef<Controller | null>(null);
const callbacks = React.useRef({ onValueChange, singleLine });
const composing = React.useRef(false);
const [chips, setChips] = React.useState<ChipHost[]>([]);
// Keep this text child immutable: SSR/hydration gets the complete draft,
// while subsequent React renders leave the browser-owned editable DOM alone.
const initial = React.useRef({ value: normalizePathInputValue(value, singleLine), autoFocus, singleLine });
React.useImperativeHandle(forwardedRef, () => rootRef.current!, []);
React.useLayoutEffect(() => { callbacks.current = { onValueChange, singleLine }; }, [onValueChange, singleLine]);
React.useLayoutEffect(() => {
const root = rootRef.current!;
const document = root.ownerDocument;
let current = normalizePathInputValue(initial.current.value, initial.current.singleLine);
let saved: TextSelection = { anchor: current.length, focus: current.length };
let beforeEdit: PathEditSnapshot | null = null;
let compositionStart: PathEditSnapshot | null = null;
let pendingExternal: string | null = null;
let pendingPaths: string[] = [];
let pendingPaste: string[] = [];
let mounted = true;
const history = new PathInputHistory();
function remember() {
if (document.activeElement !== root) return;
const selection = readPathSelection(root);
if (selection) saved = selection;
}
function snapshot(): PathEditSnapshot {
return { value: current, selection: { ...saved } };
}
function paint(selection: TextSelection, restore: boolean) {
setChips(renderPathInput(root, current));
saved = clampPathSelection(current, selection);
if (restore) restorePathSelection(root, saved);
}
function change(next: PathEditSnapshot, record = true, before = snapshot(), notify = true) {
next = normalizePathEdit(next, callbacks.current.singleLine);
if (next.value === current) { paint(next.selection, document.activeElement === root); return; }
if (record) history.push(before);
current = next.value;
paint(next.selection, document.activeElement === root);
if (notify) callbacks.current.onValueChange(current);
}
function replace(text: string) {
remember();
change(replacePathSelection(current, saved, text));
}
function travel(direction: "undo" | "redo") {
remember();
const next = history[direction](snapshot());
if (next) change(next, false);
}
function stage(paths: string[]) {
if (composing.current) { pendingPaths.push(...paths); return; }
remember();
// Global drop may have moved focus: saved selection is still authoritative.
let next: PathEditSnapshot;
try { next = insertFilePaths(current, saved, paths); }
catch { return; } // Invalid event payloads never mutate learner text.
change(next);
root.focus();
restorePathSelection(root, saved);
}
function insertEvent(event: Event) {
const paths: unknown = (event as CustomEvent<unknown>).detail;
if (Array.isArray(paths) && paths.every((path) => typeof path === "string")) stage(paths);
}
function beforeInput(event: InputEvent) {
if (composing.current || event.isComposing) return;
remember();
beforeEdit = snapshot();
if (event.inputType === "historyUndo" || event.inputType === "historyRedo") {
event.preventDefault();
travel(event.inputType === "historyUndo" ? "undo" : "redo");
} else if (event.inputType === "insertParagraph" || event.inputType === "insertLineBreak") {
event.preventDefault();
if (!callbacks.current.singleLine) replace("\n");
} else if (event.inputType.startsWith("format") || event.inputType === "insertHTML") {
event.preventDefault();
} else if (event.inputType === "deleteByCut") {
// The cut handler owns both the full-source clipboard and deletion.
// Never perform a second deletion if a browser also sends beforeinput.
event.preventDefault();
} else if (/^delete(?:(?:Word|SoftLine|HardLine)(?:Backward|Forward)|EntireSoftLine)$/.test(event.inputType)) {
// Modifier keys have platform-specific word/visual-line semantics.
// Only the browser knows that extent; never substitute an adjacent chip.
let targets: StaticRange[] = [];
try { targets = event.getTargetRanges?.() ?? []; } catch { /* Native fallback below. */ }
const range = targets.length === 1 ? pathInputStaticRange(root, targets[0]!) : null;
if (range && event.cancelable) {
event.preventDefault();
// replacePathSelection expands any intersected chip atomically.
change(replacePathSelection(current, range, ""));
}
// Missing/invalid/non-single targets: native edit + input projection owns it.
} else if (event.inputType.startsWith("delete")) {
const range = adjacentPathRange(current, saved, event.inputType.includes("Backward") ? "backward" : "forward");
if (range) {
event.preventDefault();
change(replacePathSelection(current, range, ""));
}
}
}
function input() {
if (composing.current) {
root.dataset.empty = normalizePathInputValue(readPathInput(root), callbacks.current.singleLine).length === 0 ? "true" : "false";
return;
}
const nextValue = readPathInput(root);
const selection = readPathSelection(root) ?? saved;
const previous = beforeEdit ?? snapshot();
beforeEdit = null;
change({ value: nextValue, selection }, true, previous);
}
function keyDown(event: KeyboardEvent) {
if (composing.current || event.isComposing || event.keyCode === 229) return;
// Shift+Delete is native Cut on Windows/Linux, not forward deletion.
// Let the browser dispatch cut so our clipboard handler can own it.
if (event.key === "Delete" && event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) return;
remember();
if ((event.metaKey || event.ctrlKey) && !event.altKey && (event.key.toLowerCase() === "z" || event.key.toLowerCase() === "y")) {
event.preventDefault();
travel(event.key.toLowerCase() === "y" || event.shiftKey ? "redo" : "undo");
} else if ((event.key === "Backspace" || event.key === "Delete") && !event.metaKey && !event.ctrlKey && !event.altKey) {
const range = adjacentPathRange(current, saved, event.key === "Backspace" ? "backward" : "forward");
if (range) {
event.preventDefault();
change(replacePathSelection(current, range, ""));
}
}
}
function paste(event: ClipboardEvent) {
if (event.defaultPrevented) return;
event.preventDefault();
const text = event.clipboardData?.getData("text/plain") ?? "";
// Image/file/HTML-only clipboard contents are not text replacement.
if (text === "") return;
if (composing.current) pendingPaste.push(text);
else replace(text);
}
function copy(event: ClipboardEvent) {
if (event.defaultPrevented) return;
remember();
if (saved.anchor === saved.focus || !event.clipboardData) return;
const { start, end } = atomicPathRange(current, saved);
event.clipboardData.setData("text/plain", current.slice(start, end));
event.preventDefault();
if (event.type === "cut" && !composing.current) change(replacePathSelection(current, saved, ""));
}
function startComposition() {
remember();
composing.current = true;
compositionStart = snapshot();
}
function endComposition() {
// Some browsers send the final input immediately AFTER compositionend.
queueMicrotask(() => {
if (!mounted) return;
let nextValue = readPathInput(root);
let selection = readPathSelection(root) ?? saved;
const previous = compositionStart ?? snapshot();
const reset = pendingExternal !== null && !pendingExternal.startsWith(previous.value);
const nativeChanged = normalizePathInputValue(nextValue, callbacks.current.singleLine) !== current;
const notify = pendingExternal === null || (!reset && nativeChanged);
if (pendingExternal !== null) {
if (pendingExternal.startsWith(previous.value)) {
nextValue += pendingExternal.slice(previous.value.length);
} else {
nextValue = pendingExternal;
selection = clampPathSelection(nextValue, selection);
}
}
pendingExternal = null;
compositionStart = null;
beforeEdit = null;
composing.current = false;
if (reset) history.reset();
change({ value: nextValue, selection }, !reset, previous, notify);
if (pendingPaste.length) { const text = pendingPaste.join(""); pendingPaste = []; replace(text); }
if (pendingPaths.length) { const paths = pendingPaths; pendingPaths = []; stage(paths); }
});
}
controllerRef.current = {
remember,
external(next) {
next = normalizePathInputValue(next, callbacks.current.singleLine);
if (composing.current) { pendingExternal = next; return; }
if (next === current) return;
remember();
const previous = snapshot();
// Dictation appends are undoable; a controlled reset starts fresh history.
if (next && next.startsWith(current)) history.push(previous);
else history.reset();
const selection = saved.anchor === current.length && saved.focus === current.length
? { anchor: next.length, focus: next.length } : clampPathSelection(next, saved);
current = next;
paint(selection, document.activeElement === root);
},
};
paint(saved, false);
if (initial.current.autoFocus) { root.focus(); restorePathSelection(root, saved); }
root.addEventListener(INSERT_FILE_PATHS_EVENT, insertEvent);
root.addEventListener("beforeinput", beforeInput);
root.addEventListener("input", input);
root.addEventListener("keydown", keyDown);
root.addEventListener("paste", paste);
root.addEventListener("copy", copy);
root.addEventListener("cut", copy);
root.addEventListener("compositionstart", startComposition);
root.addEventListener("compositionend", endComposition);
document.addEventListener("selectionchange", remember);
return () => {
mounted = false;
composing.current = false;
controllerRef.current = null;
root.removeEventListener(INSERT_FILE_PATHS_EVENT, insertEvent);
root.removeEventListener("beforeinput", beforeInput);
root.removeEventListener("input", input);
root.removeEventListener("keydown", keyDown);
root.removeEventListener("paste", paste);
root.removeEventListener("copy", copy);
root.removeEventListener("cut", copy);
root.removeEventListener("compositionstart", startComposition);
root.removeEventListener("compositionend", endComposition);
document.removeEventListener("selectionchange", remember);
};
}, []);
React.useLayoutEffect(() => { controllerRef.current?.external(value); }, [value, singleLine]);
return <>
<div
{...props}
ref={rootRef}
role="textbox"
aria-label={props["aria-label"] ?? placeholder}
aria-multiline={singleLine ? "false" : "true"}
aria-placeholder={placeholder}
contentEditable
suppressContentEditableWarning
data-file-path-input=""
data-empty={initial.current.value.length === 0 ? "true" : "false"}
data-file-path-default={defaultDropTarget ? "" : undefined}
data-placeholder={placeholder}
className={cn("block w-full min-w-0 outline-none data-[empty=true]:before:pointer-events-none data-[empty=true]:before:text-muted-foreground data-[empty=true]:before:content-[attr(data-placeholder)]", singleLine ? "whitespace-pre break-normal overflow-x-auto overflow-y-hidden" : "whitespace-pre-wrap break-words overflow-y-auto", className)}
onKeyDown={(event) => {
if (composing.current || event.nativeEvent.isComposing || event.keyCode === 229) { event.stopPropagation(); return; }
onKeyDown?.(event);
}}
onFocus={(event) => { controllerRef.current?.remember(); onFocus?.(event); }}
onBlur={(event) => { controllerRef.current?.remember(); onBlur?.(event); }}
onInput={onInput}
onPaste={onPaste}
onCopy={onCopy}
onCut={onCut}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
>{initial.current.value}</div>
{chips.map(({ host, path }) => createPortal(<FilePathChip path={path} contentEditable={false} />, host))}
</>;
});
+5 -5
View File
@@ -6,6 +6,7 @@ import { ToolContentResponse } from "@/components/ai-elements/message";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { joinDictation, useDictationTarget } from "@/components/dictation"; import { joinDictation, useDictationTarget } from "@/components/dictation";
import { useEnterSubmits } from "@/components/enter-submits"; import { useEnterSubmits } from "@/components/enter-submits";
import { PathInput } from "@/components/path-input";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** /**
@@ -121,11 +122,11 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
<div className="px-2.5 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="px-2.5 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
YOUR THINKING YOUR THINKING
</div> </div>
<textarea <PathInput
value={thinking} value={thinking}
onChange={(e) => { onValueChange={(value) => {
thinkingRef.current = e.target.value; thinkingRef.current = value;
setThinking(e.target.value); setThinking(value);
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
// Esc ends dictation — stopPropagation claims it (the // Esc ends dictation — stopPropagation claims it (the
@@ -144,7 +145,6 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
} }
}} }}
placeholder="Work here, think out loud." placeholder="Work here, think out loud."
rows={4}
className={cn( className={cn(
"field-sizing-content lh-input max-h-64 min-h-16 w-full resize-none bg-transparent px-2.5 pb-2 outline-none placeholder:text-muted-foreground", "field-sizing-content lh-input max-h-64 min-h-16 w-full resize-none bg-transparent px-2.5 pb-2 outline-none placeholder:text-muted-foreground",
)} )}
+16 -16
View File
@@ -5,6 +5,7 @@ import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { finishDictation, isDictationActive, joinDictation, useDictationTarget } from "@/components/dictation"; import { finishDictation, isDictationActive, joinDictation, useDictationTarget } from "@/components/dictation";
import { useEnterSubmits } from "@/components/enter-submits"; import { useEnterSubmits } from "@/components/enter-submits";
import { PathInput } from "@/components/path-input";
import { ToolContentResponse } from "@/components/ai-elements/message"; import { ToolContentResponse } from "@/components/ai-elements/message";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@@ -439,8 +440,8 @@ function QuestionDockForm({
expanded — collapsed, the bar's native Enter expands it. */ expanded — collapsed, the bar's native Enter expands it. */
const [kbdIdx, setKbdIdx] = useState<number | null>(null); const [kbdIdx, setKbdIdx] = useState<number | null>(null);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const otherInputRef = useRef<HTMLInputElement>(null); const otherInputRef = useRef<HTMLDivElement>(null);
const thinkingRef = useRef<HTMLTextAreaElement>(null); const thinkingRef = useRef<HTMLDivElement>(null);
// One row past the options is the trailing row: quiz → don't-know, // One row past the options is the trailing row: quiz → don't-know,
// ask → Other (a question never has both). Text mode (ask/explain) // ask → Other (a question never has both). Text mode (ask/explain)
// has no rows — the composer IS the answer surface. // has no rows — the composer IS the answer surface.
@@ -473,7 +474,7 @@ function QuestionDockForm({
const focusRow = (idx: number) => { const focusRow = (idx: number) => {
const row = rootRef.current?.querySelector(`[data-kbdrow="${idx}"]`) as HTMLElement | null; const row = rootRef.current?.querySelector(`[data-kbdrow="${idx}"]`) as HTMLElement | null;
if (!row) return; if (!row) return;
const el = (row.querySelector("input") as HTMLElement | null) ?? row; const el = (row.querySelector("input, [data-file-path-input]") as HTMLElement | null) ?? row;
el.focus(); el.focus();
}; };
@@ -696,11 +697,12 @@ function QuestionDockForm({
</span> </span>
)} )}
</span> </span>
<input <PathInput
ref={otherInputRef} ref={otherInputRef}
singleLine
value={other} value={other}
onChange={(e) => { onValueChange={(value) => {
setOther(e.target.value); setOther(value);
// Typing selects the row — displacing in // Typing selects the row — displacing in
// single-select, alongside the siblings in multi. // single-select, alongside the siblings in multi.
setSel((prev) => (prev.otherSelected ? prev : toggleOther(selectMode, prev))); setSel((prev) => (prev.otherSelected ? prev : toggleOther(selectMode, prev)));
@@ -851,11 +853,11 @@ function QuestionDockForm({
{qd.mode === "text" ? "Your answer" : "YOUR THINKING"} {qd.mode === "text" ? "Your answer" : "YOUR THINKING"}
</div> </div>
{qd.mode === "text" ? ( {qd.mode === "text" ? (
<textarea <PathInput
value={text} value={text}
onChange={(e) => { onValueChange={(value) => {
answerTextRef.current = e.target.value; answerTextRef.current = value;
setText(e.target.value); setText(value);
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
// Esc ends dictation (the dictate decisions: click-again // Esc ends dictation (the dictate decisions: click-again
@@ -875,16 +877,15 @@ function QuestionDockForm({
} }
}} }}
placeholder="Type your answer…" placeholder="Type your answer…"
rows={1}
className="field-sizing-content lh-input max-h-48 min-h-10 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground" className="field-sizing-content lh-input max-h-48 min-h-10 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
/> />
) : ( ) : (
<textarea <PathInput
ref={thinkingRef} ref={thinkingRef}
value={thinking} value={thinking}
onChange={(e) => { onValueChange={(value) => {
thinkingTextRef.current = e.target.value; thinkingTextRef.current = value;
setThinking(e.target.value); setThinking(value);
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
// Esc ends dictation (the dictate decisions: click-again // Esc ends dictation (the dictate decisions: click-again
@@ -905,7 +906,6 @@ function QuestionDockForm({
} }
}} }}
placeholder="Why this answer? Whatever comes to mind." placeholder="Why this answer? Whatever comes to mind."
rows={1}
className={cn( className={cn(
"field-sizing-content lh-input max-h-48 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground/70", "field-sizing-content lh-input max-h-48 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground/70",
// Phase-scaled prominence: teaching invites depth, // Phase-scaled prominence: teaching invites depth,
+3 -3
View File
@@ -2,6 +2,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { joinDictation, useDictationTarget } from "@/components/dictation"; import { joinDictation, useDictationTarget } from "@/components/dictation";
import { PathInput } from "@/components/path-input";
/** /**
* The reading-thinking field (owner design 2026-10-03, "two questions per * The reading-thinking field (owner design 2026-10-03, "two questions per
@@ -54,9 +55,9 @@ export function ReadingThinkingField({
<div className="pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Thinking while reading Thinking while reading
</div> </div>
<textarea <PathInput
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onValueChange={onChange}
/* py-0.5 (owner round, 2026-10-07): the scrollbar fix. Source /* py-0.5 (owner round, 2026-10-07): the scrollbar fix. Source
Sans 3's font box is 22px at 16px; lh-input's 1.25 leading Sans 3's font box is 22px at 16px; lh-input's 1.25 leading
gives a 20px line box — the content area sticks 1px below the gives a 20px line box — the content area sticks 1px below the
@@ -95,7 +96,6 @@ export function ReadingThinkingField({
} }
}} }}
placeholder="Whatever comes to mind as you read." placeholder="Whatever comes to mind as you read."
rows={1}
className="field-sizing-content lh-input max-h-40 w-full resize-none bg-transparent py-0.5 outline-none placeholder:text-muted-foreground/70" className="field-sizing-content lh-input max-h-40 w-full resize-none bg-transparent py-0.5 outline-none placeholder:text-muted-foreground/70"
/> />
</div> </div>
+1 -1
View File
@@ -4,7 +4,7 @@ import { findSessionTextMatches } from "@/src/core";
* hidden/unmounted sections do not. Never walk embedded documents or drafts. * hidden/unmounted sections do not. Never walk embedded documents or drafts.
* Do NOT exclude aria-hidden globally: KaTeX's VISIBLE HTML uses it too. */ * Do NOT exclude aria-hidden globally: KaTeX's VISIBLE HTML uses it too. */
const EXCLUDED = [ const EXCLUDED = [
"script", "style", "noscript", "textarea", "input", "select", "iframe", "canvas", "script", "style", "noscript", "textarea", "input", "select", "iframe", "canvas", '[contenteditable="true"]',
"title", "defs", "metadata", "desc", ".katex-mathml", ".sr-only", "[hidden]", "title", "defs", "metadata", "desc", ".katex-mathml", ".sr-only", "[hidden]",
"[data-session-find-exclude]", "[data-lh-menu]", '[role="menu"]', '[role="dialog"]', '[role="tooltip"]', "[data-session-find-exclude]", "[data-lh-menu]", '[role="menu"]', '[role="dialog"]', '[role="tooltip"]',
].join(","); ].join(",");
+4 -1
View File
@@ -50,6 +50,7 @@ const STORAGE_KEY = "lh:sidebar-open";
*/ */
import type { ChatGPTControlBridge, NativeSubscriptionControlBridge } from "@/src/core"; import type { ChatGPTControlBridge, NativeSubscriptionControlBridge } from "@/src/core";
import type { UpdateState } from "@/components/settings/update-settings.tsx"; import type { UpdateState } from "@/components/settings/update-settings.tsx";
import { FilePathDropZone } from "@/components/file-path-drop-zone";
const SIDEBAR_AUTOCLOSE = 928; const SIDEBAR_AUTOCLOSE = 928;
// Module scope, guarded for SSR (the server has no navigator). // Module scope, guarded for SSR (the server has no navigator).
@@ -61,6 +62,8 @@ declare global {
lh?: { lh?: {
/** Read-only host OS; determines the native-control gutter. */ /** Read-only host OS; determines the native-control gutter. */
platform?: string; platform?: string;
/** Resolve an OS-backed File without copying/uploading its contents. */
getPathForFile?: (file: File) => string;
/** External Next desktop-dev uses the server's web connections. */ /** External Next desktop-dev uses the server's web connections. */
subscriptionTransport?: "desktop" | "web"; subscriptionTransport?: "desktop" | "web";
openSubscriptionBrowser?: (action: string) => Promise<void>; openSubscriptionBrowser?: (action: string) => Promise<void>;
@@ -944,7 +947,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
return ( return (
<SettingsDialogProvider> <SettingsDialogProvider>
<SessionsSidebar /> <SessionsSidebar />
<main className="min-w-0 flex-1">{children}</main> <FilePathDropZone className="relative min-w-0 flex-1">{children}</FilePathDropZone>
{/* The single document-level keydown listener (keyboard wave A) — {/* The single document-level keydown listener (keyboard wave A) —
after the content in the tree, but it's window-level so order only after the content in the tree, but it's window-level so order only
matters for the events it dispatches, never for DOM priority. */} matters for the events it dispatches, never for DOM priority. */}
+2 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { ChevronDownIcon } from "lucide-react"; import { ChevronDownIcon } from "lucide-react";
import { blurAfterPointerClick } from "@/components/focus-hygiene"; import { blurAfterPointerClick } from "@/components/focus-hygiene";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PathText } from "@/components/file-path-chip";
/** The unified user-thinking row (owner round, 2026-10-05): ONE visual /** The unified user-thinking row (owner round, 2026-10-05): ONE visual
* language for every surface where the learner's own thinking renders — * language for every surface where the learner's own thinking renders —
@@ -127,7 +128,7 @@ export function ThinkingBlock({
<ChevronDownIcon className={cn("size-4 shrink-0 transition-transform", open ? "rotate-180" : "rotate-0")} /> <ChevronDownIcon className={cn("size-4 shrink-0 transition-transform", open ? "rotate-180" : "rotate-0")} />
</button> </button>
{open && ( {open && (
<span className="lh-prose mt-1.5 block whitespace-pre-wrap text-[15px] italic text-foreground/50">{text}</span> <span className="lh-prose mt-1.5 block whitespace-pre-wrap text-[15px] italic text-foreground/50"><PathText text={text} /></span>
)} )}
</div> </div>
); );
+4 -1
View File
@@ -29,7 +29,7 @@
* *
* Channel names are mirrored in electron/main.cjs — keep them in sync. * Channel names are mirrored in electron/main.cjs — keep them in sync.
*/ */
const { contextBridge, ipcRenderer } = require("electron"); const { contextBridge, ipcRenderer, webUtils } = require("electron");
let openSettingsListener = null; let openSettingsListener = null;
let pendingOpenSettings = null; let pendingOpenSettings = null;
@@ -40,6 +40,9 @@ ipcRenderer.on("lh:open-settings", (_event, section) => {
contextBridge.exposeInMainWorld("lh", { contextBridge.exposeInMainWorld("lh", {
platform: process.platform, platform: process.platform,
// Resolve only a renderer-supplied, OS-backed File. No filesystem API,
// uploads, copies, or DOM File IPC to main; sandbox/isolation stay on.
getPathForFile: (file) => webUtils.getPathForFile(file),
...(process.argv?.includes("--lh-web-subscriptions") ? { ...(process.argv?.includes("--lh-web-subscriptions") ? {
subscriptionTransport: "web", subscriptionTransport: "web",
openSubscriptionBrowser: (action) => ipcRenderer.invoke("lh:subscriptions:web-browser", action), openSubscriptionBrowser: (action) => ipcRenderer.invoke("lh:subscriptions:web-browser", action),
+6
View File
@@ -78,6 +78,11 @@ const nextConfig: NextConfig = {
// npm installs only the current platform's binary, so this glob // npm installs only the current platform's binary, so this glob
// resolves to exactly one Skia binary per build. // resolves to exactly one Skia binary per build.
"node_modules/@napi-rs/canvas-*/**/*", "node_modules/@napi-rs/canvas-*/**/*",
// Pi-compatible image resizing runs in an unbundled worker. Photon
// loads its colocated WASM through Node, also in packaged Electron.
"src/agent/image-processing/*.mjs",
"src/agent/image-processing/LICENSE",
"node_modules/@silvia-odwyer/photon-node/**/*",
// Wave E stage 2: resvg rasterizes visual-maker SVGs natively; same // Wave E stage 2: resvg rasterizes visual-maker SVGs natively; same
// per-platform-binary story as the canvas above (one per build). // per-platform-binary story as the canvas above (one per build).
"node_modules/@resvg/resvg-js-*/**/*", "node_modules/@resvg/resvg-js-*/**/*",
@@ -106,6 +111,7 @@ const nextConfig: NextConfig = {
"@earendil-works/pi-agent-core", "@earendil-works/pi-agent-core",
"pdfjs-dist", // sources v0: bundling breaks its node fake-worker path "pdfjs-dist", // sources v0: bundling breaks its node fake-worker path
"@napi-rs/canvas", // native binding — bundling breaks the .node require "@napi-rs/canvas", // native binding — bundling breaks the .node require
"@silvia-odwyer/photon-node", // CJS loads its adjacent WASM from disk
"@resvg/resvg-js", // native binding — same "@resvg/resvg-js", // native binding — same
], ],
}; };
+7
View File
@@ -15,6 +15,7 @@
"@radix-ui/react-use-controllable-state": "^1.2.6", "@radix-ui/react-use-controllable-state": "^1.2.6",
"@resvg/resvg-js": "^2.6.2", "@resvg/resvg-js": "^2.6.2",
"@rive-app/react-webgl2": "^4.32.1", "@rive-app/react-webgl2": "^4.32.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@streamdown/cjk": "^1.0.3", "@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1", "@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2", "@streamdown/math": "^1.0.2",
@@ -4777,6 +4778,12 @@
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@silvia-odwyer/photon-node": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz",
"integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==",
"license": "Apache-2.0"
},
"node_modules/@sindresorhus/is": { "node_modules/@sindresorhus/is": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
+1
View File
@@ -30,6 +30,7 @@
"@radix-ui/react-use-controllable-state": "^1.2.6", "@radix-ui/react-use-controllable-state": "^1.2.6",
"@resvg/resvg-js": "^2.6.2", "@resvg/resvg-js": "^2.6.2",
"@rive-app/react-webgl2": "^4.32.1", "@rive-app/react-webgl2": "^4.32.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@streamdown/cjk": "^1.0.3", "@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1", "@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2", "@streamdown/math": "^1.0.2",
+425
View File
@@ -0,0 +1,425 @@
/** Offline synthetic UI + actual Electron preload smoke. No providers/accounts.
* Run: node scripts/smoke-file-paths.mjs
* App/profile/auth/OS-backed fixture files are temporary; only this script's
* processes are stopped. Browser requests outside the owned origin are blocked.
*/
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import fs from "node:fs";
import net from "node:net";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import puppeteer from "puppeteer-core";
const root = path.resolve(import.meta.dirname, "..");
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-file-paths-"));
const require = createRequire(import.meta.url);
const checks = [];
const errors = [];
const visualFailures = [];
const coverageFailures = [];
const posts = [];
const blocked = [];
const networkFailures = [];
const consoleErrors = [];
const children = [];
const browsers = [];
const screenshots = [];
let serverLogs = "";
let electronLogs = "";
let page;
let origin;
let shuttingDown = false;
const fixtureCatalog = (() => {
const model = { id: "synthetic", name: "Synthetic (no calls)", provider: "fixture", input: ["text"], reasoning: false, supportedLevels: ["off"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024 };
const slot = { model: { provider: model.provider, id: model.id }, thinkingLevel: "off" };
return { models: [model], providers: [{ id: "fixture", label: "Fixture", keyPresent: true }], defaults: null, slots: { strong: slot, cheap: slot, vision: slot }, rolePins: {} };
})();
async function freePort() {
const listener = net.createServer();
listener.listen(0, "127.0.0.1");
await once(listener, "listening");
const port = listener.address().port;
await new Promise(resolve => listener.close(resolve));
return port;
}
async function stop(child) {
if (!child || child.exitCode !== null || child.signalCode !== null) return;
const exited = once(child, "exit");
child.kill("SIGTERM");
const timer = setTimeout(() => child.kill("SIGKILL"), 5000); timer.unref();
await exited; clearTimeout(timer);
}
async function startup(child, getLogs, marker, timeout = 90000) {
await new Promise((resolve, reject) => {
const timer = setTimeout(() => finish(new Error(`Startup timed out: ${getLogs()}`)), timeout);
const inspect = () => { if (marker.test(getLogs())) finish(); };
const exited = code => finish(new Error(`Process exited ${code}: ${getLogs()}`));
function finish(error) {
clearTimeout(timer); child.stdout.off("data", inspect); child.stderr.off("data", inspect); child.off("exit", exited);
if (error) reject(error); else resolve();
}
child.stdout.on("data", inspect); child.stderr.on("data", inspect); child.once("exit", exited); inspect();
});
}
async function instrument(target) {
target.setDefaultTimeout(20000);
target.on("pageerror", error => { if (!shuttingDown) errors.push(error.message); });
target.on("requestfailed", request => { if (!shuttingDown) networkFailures.push({ url: request.url(), error: request.failure()?.errorText }); });
target.on("console", message => { if (message.type() === "error") consoleErrors.push(message.text()); });
await target.setRequestInterception(true);
target.on("request", async request => {
try {
const url = new URL(request.url());
if (url.protocol === "data:" || url.protocol === "about:") return await request.continue();
if (url.origin !== origin) { blocked.push(request.url()); return await request.abort(); }
const respond = body => request.respond({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
if (url.pathname === "/api/models") return await respond(fixtureCatalog);
if (url.pathname === "/api/events") return await request.respond({ status: 204 });
// Never forward agent, answer or import requests, even if a UI bug
// submits while dropping. Record their complete synthetic bodies.
if (request.method() === "POST" && (/^\/api\/(chat|answer)(\/|$)/.test(url.pathname) || /\/(answer|import|turn|retry)(\/|$)/.test(url.pathname))) {
posts.push({ pathname: url.pathname, body: JSON.parse(request.postData() || "{}") });
return await request.respond({ status: 503, contentType: "application/json", body: JSON.stringify({ code: "models_not_configured", missing: ["strong"] }) });
}
if (/^\/api\/chats\/[^/]+\/stream$/.test(url.pathname)) return await request.respond({ status: 204 });
return await request.continue();
} catch (error) {
if (!shuttingDown && !/Target closed|Session closed|Invalid InterceptionId|already handled/.test(String(error))) errors.push(String(error));
}
});
}
const inputSelector = '[data-file-path-input]';
async function open(target, route) {
await target.goto(origin + route, { waitUntil: "domcontentloaded", timeout: 120000 });
await target.waitForSelector(inputSelector);
await target.waitForFunction(() => !document.querySelector('[data-testid="setup-cta"], [data-testid="setup-cta-keys"]'));
await target.waitForFunction(() => document.querySelector('[data-testid="file-path-drop-zone"]'));
}
async function read(target, selector = inputSelector) {
return target.$eval(selector, element => [...element.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join(""));
}
async function expectValue(target, expected, selector = inputSelector) {
await target.waitForFunction((selector, expected) => {
const element = document.querySelector(selector);
return element && [...element.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join("") === expected;
}, {}, selector, expected);
}
async function fill(target, value, selector = inputSelector) {
await target.$eval(selector, (element, value) => {
element.focus(); element.textContent = value;
const selection = document.getSelection(); selection.collapse(element, element.childNodes.length);
element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" }));
}, value);
await expectValue(target, value, selector);
}
async function caret(target, offset, selector = inputSelector) {
await target.$eval(selector, (element, offset) => {
element.focus(); let rest = offset;
for (const node of element.childNodes) {
const source = node.nodeType === 1 ? node.getAttribute("data-file-path-source") : null;
const length = source?.length ?? node.textContent.length;
if (rest <= length) {
if (source !== null) {
const index = [...element.childNodes].indexOf(node);
document.getSelection().collapse(element, index + (rest > 0 ? 1 : 0));
} else document.getSelection().collapse(node, rest);
document.dispatchEvent(new Event("selectionchange")); return;
}
rest -= length;
}
document.getSelection().collapse(element, element.childNodes.length);
document.dispatchEvent(new Event("selectionchange"));
}, offset);
}
async function shortcut(target, key, shift = false) {
await target.keyboard.down("Meta"); if (shift) await target.keyboard.down("Shift");
await target.keyboard.press(key);
if (shift) await target.keyboard.up("Shift"); await target.keyboard.up("Meta");
}
async function clipboardCommand(target, command, key, modifiers = 4) {
// Headless Chromium does not run the macOS menu accelerator for Meta+C/V.
// CDP's native edit command triggers the real browser clipboard event.
const session = await target.createCDPSession();
try { await session.send("Input.dispatchKeyEvent", { type: "rawKeyDown", key: key ?? (command === "copy" ? "c" : "v"), modifiers, commands: [command] }); }
finally { await session.detach(); }
}
async function syntheticDrop(target, paths, selector = '[data-testid="file-path-drop-zone"]') {
return target.$eval(selector, (zone, paths) => {
const transfer = new DataTransfer();
window.__syntheticPaths = new WeakMap();
window.lh = { platform: "darwin", getPathForFile(file) { return window.__syntheticPaths.get(file) ?? ""; } };
for (const [index, path] of paths.entries()) {
const file = new File(["never read"], `synthetic-display-${index}.pdf`, { type: "application/pdf" });
window.__syntheticPaths.set(file, path);
for (const method of ["text", "arrayBuffer", "stream"]) Object.defineProperty(file, method, { value() { throw new Error("File bytes must not be read"); } });
transfer.items.add(file);
}
const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer });
zone.dispatchEvent(event); return event.defaultPrevented;
}, paths);
}
async function blur(target) { await target.evaluate(() => { document.activeElement?.blur(); }); }
function seedChatFixtures() {
const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
const transcript = Array.from({ length: 100 }, (_, index) => `Synthetic lesson paragraph ${index}: one and one make two. These are offline fixture notes.`).join("\n\n");
const assistant = (id, content) => ({ id, role: "assistant", api: "anthropic-messages", provider: "anthropic", model: "synthetic", timestamp: 1, stopReason: "toolUse", usage, content });
const call = (id, name, args) => ({ type: "toolCall", id, name, arguments: args });
const result = (id, toolCallId, toolName, details) => ({ id, role: "toolResult", toolCallId, toolName, content: [{ type: "text", text: "Synthetic tool completed." }], ...(details ? { details } : {}), isError: false, timestamp: 2 });
const quiz = { type: "check", nodeId: "addition", question: "What is 1 + 1?", options: [{ value: "two", label: "2" }, { value: "three", label: "3" }], explanation: "Adding one to one gives two.", correctAnswer: "two" };
const records = [
{ id: "path_teach", messages: [assistant("m_probe", [{ type: "text", text: transcript }, call("call_probe", "probe_done", { assessment: { strands: [{ strand: "Addition", assessment: "Ready for a check." }] } })]), result("m_probe_result", "call_probe", "probe_done", { ok: true }), assistant("m_quiz", [call("call_quiz", "quiz", quiz)])] },
{ id: "path_probe", messages: [assistant("m_probe_question", [{ type: "text", text: transcript }, call("call_probe_question", "probe", quiz)])] },
{ id: "path_practice", kind: "practice", messages: [assistant("m_problem", [{ type: "text", text: transcript }, call("call_problem", "problem", { statement: "Compute 1 + 1. Explain your reasoning.", mode: "recall", modelSolution: "1 + 1 = 2." })]), result("m_problem_result", "call_problem", "problem")] },
];
for (const record of records) {
record.messages.unshift({ id: "sent_user", role: "user", content: "Attached `/synthetic/transcript notes.pdf`", timestamp: 0 });
fs.writeFileSync(path.join(home, "data", "chats", `${record.id}.json`), JSON.stringify({ version: 1, title: `Synthetic ${record.id}`, createdAt: "2026-10-28T00:00:00.000Z", updatedAt: "2026-10-28T00:00:00.000Z", ...record }));
}
}
async function foldDock(target, barSelector) {
// Let growth/geometry muting settle, then use a real wheel gesture so
// stick-to-bottom records user intent instead of a programmatic clamp.
// The explicit reveal owns a bounded 2s fold-suppression window.
await target.evaluate(() => new Promise(resolve => setTimeout(resolve, 2250)));
const log = await target.$('[role="log"]'); const box = await log.boundingBox();
await target.mouse.move(box.x + box.width / 2, box.y + 100);
// Multiple real wheel frames let stick-to-bottom publish isAtBottom
// before DockOverlay evaluates the next away-from-bottom scroll event.
for (const deltaY of [-420, -420, -840]) {
await target.mouse.wheel({ deltaY });
await target.evaluate(() => new Promise(resolve => setTimeout(resolve, 100)));
}
try { await target.waitForSelector(barSelector, { visible: true, timeout: 5000 }); return true; }
catch {
coverageFailures.push({ url: target.url(), case: barSelector, message: "Native wheel did not expose collapsed bar", scrollers: await target.evaluate(() => [...document.querySelectorAll('[role="log"], [role="log"] *')].filter(element => element.scrollHeight > element.clientHeight + 100 && getComputedStyle(element).overflowY !== "visible").map(element => ({ tag: element.tagName, className: element.className, scrollTop: element.scrollTop, scrollHeight: element.scrollHeight, clientHeight: element.clientHeight }))) });
return false;
}
}
try {
fs.mkdirSync(path.join(home, "data", "chats"), { recursive: true });
fs.writeFileSync(path.join(home, "auth.json"), "{}\n");
seedChatFixtures();
const { getFontAxes } = require("next/dist/compiled/@next/font/dist/google/get-font-axes.js");
const { getGoogleFontsUrl } = require("next/dist/compiled/@next/font/dist/google/get-google-fonts-url.js");
const fonts = Object.fromEntries(["JetBrains Mono", "Source Sans 3"].map(family => [getGoogleFontsUrl(family, getFontAxes(family, ["variable"], ["normal"]), "swap"), `@font-face { font-family: '${family}'; src: local('Arial'); font-weight: 100 900; }`]));
fs.writeFileSync(path.join(home, "fonts.cjs"), `module.exports = ${JSON.stringify(fonts)};\n`);
const port = await freePort(); origin = `http://127.0.0.1:${port}`;
const env = { PATH: process.env.PATH, HOME: home, TMPDIR: home, NODE_ENV: "development", LH_DATA_DIR: path.join(home, "data"), LH_AUTH_FILE: path.join(home, "auth.json"), LH_IGNORE_ENV_KEYS: "1", NEXT_TELEMETRY_DISABLED: "1", NEXT_FONT_GOOGLE_MOCKED_RESPONSES: path.join(home, "fonts.cjs") };
const server = spawn(process.execPath, ["node_modules/next/dist/bin/next", "dev", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] }); children.push(server);
const captureServer = data => { serverLogs = (serverLogs + data).slice(-28000); };
server.stdout.on("data", captureServer); server.stderr.on("data", captureServer);
await startup(server, () => serverLogs, /Ready in/);
if (!process.argv.includes("--desktop-only")) {
const chrome = await puppeteer.launch({ executablePath: process.env.LH_TEST_CHROME || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", headless: true, userDataDir: path.join(home, "chrome-profile"), env, args: ["--disable-background-networking", "--disable-sync", "--no-first-run", "--disable-default-apps"] }); browsers.push(chrome);
page = await chrome.newPage(); await page.setViewport({ width: 1280, height: 900 }); await instrument(page);
await chrome.defaultBrowserContext().overridePermissions(origin, ["clipboard-read", "clipboard-write", "clipboard-sanitized-write"]);
const token = "`/synthetic/course notes.pdf`";
for (const route of ["/", "/chat/new"]) {
await open(page, route);
// Sidebar drops prevent browser navigation but must not stage paths or
// activate the right-content attachment indicator.
assert.equal(await page.$eval("aside", sidebar => {
const transfer = new DataTransfer(); transfer.items.add(new File(["not uploaded"], "sidebar.pdf"));
const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer }); sidebar.dispatchEvent(event); return event.defaultPrevented;
}), true);
assert.equal(await page.$('[data-testid="file-path-drop-indicator"]'), null);
await expectValue(page, "");
// Browser mode has no bridge: prevent file navigation with a clear notice.
const prevented = await page.$eval('[data-testid="file-path-drop-zone"]', zone => {
const transfer = new DataTransfer(); transfer.items.add(new File(["not uploaded"], "notes.pdf"));
const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer }); zone.dispatchEvent(event); return event.defaultPrevented;
});
assert.equal(prevented, true);
await page.waitForFunction(() => document.querySelector('[role="status"]')?.textContent.includes("desktop app"));
await fill(page, "read "); await page.keyboard.type(token + " now");
await expectValue(page, `read ${token} now`);
assert.equal(await page.$$eval('[data-file-path-input] [role="img"]', elements => elements.length), 1);
// Native browser arrows cross the atomic host without entering its label.
await caret(page, 5); await page.keyboard.press("ArrowRight");
assert.equal(await page.evaluate(() => !!document.getSelection().anchorNode?.parentElement?.closest('[role="img"]')), false);
await page.keyboard.press("Backspace"); await expectValue(page, "read now");
await shortcut(page, "KeyZ"); await expectValue(page, `read ${token} now`);
await shortcut(page, "KeyZ", true); await expectValue(page, "read now");
await shortcut(page, "KeyZ"); await caret(page, 5); await page.keyboard.press("Delete"); await expectValue(page, "read now");
await shortcut(page, "KeyZ"); await expectValue(page, `read ${token} now`);
// Select the actual rendered label; copy must emit the full source token.
await page.$eval('[data-file-path-input] [role="img"] span', label => {
const range = document.createRange(); range.selectNodeContents(label); const selection = document.getSelection(); selection.removeAllRanges(); selection.addRange(range); document.dispatchEvent(new Event("selectionchange"));
});
await clipboardCommand(page, "copy");
assert.equal(await page.evaluate(() => navigator.clipboard.readText()), token);
await clipboardCommand(page, "cut", "Delete", 8);
await expectValue(page, "read now");
assert.equal(await page.evaluate(() => navigator.clipboard.readText()), token, "Shift+Delete is native cut, not editor-only deletion");
await shortcut(page, "KeyZ"); await expectValue(page, `read ${token} now`);
// Clipboard image-only input must not erase the current selected path.
await page.$eval('[data-file-path-input]', editor => {
const host = editor.querySelector('[data-file-path-source]'); const range = document.createRange(); range.selectNode(host); const selection = document.getSelection(); selection.removeAllRanges(); selection.addRange(range); document.dispatchEvent(new Event("selectionchange"));
const data = new DataTransfer(); data.items.add(new File(["image fixture"], "image.png", { type: "image/png" }));
editor.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }));
});
await expectValue(page, `read ${token} now`);
// Pasting HTML-bearing clipboard content is plain text, at the range.
await page.evaluate(() => navigator.clipboard.writeText('<img src=x onerror="alert(1)">\n中'));
await clipboardCommand(page, "paste");
await expectValue(page, 'read <img src=x onerror="alert(1)">\n中 now');
assert.equal(await page.$('[data-file-path-input] img'), null);
await fill(page, "readthis"); await caret(page, 4);
assert.equal(await syntheticDrop(page, ["/synthetic/one.pdf", "~/two`final.pdf"]), true);
await expectValue(page, "read `/synthetic/one.pdf` ``~/two`final.pdf`` this");
assert.equal(posts.length, 0, "typing/paste/drop must never submit");
await fill(page, "one visible line"); await caret(page, "one visible line".length);
const beforeNewline = await page.$eval(inputSelector, element => element.getBoundingClientRect().height);
await page.keyboard.down("Shift"); await page.keyboard.press("Enter"); await page.keyboard.up("Shift");
await expectValue(page, "one visible line\n");
const afterNewline = await page.$eval(inputSelector, element => {
const range = document.getSelection().getRangeAt(0); const rect = range.getBoundingClientRect();
return { height: element.getBoundingClientRect().height, caret: { top: rect.top, height: rect.height }, html: element.innerHTML };
});
// Record this independently so a visual editor regression does not hide
// the higher-priority actual Electron/target-routing evidence below.
try { assert.ok(afterNewline.height > beforeNewline, `Trailing newline must occupy a visible blank line: ${JSON.stringify({ beforeNewline, afterNewline })}`); }
catch (error) { visualFailures.push({ route, message: error.message }); }
await page.keyboard.type("second line");
const afterTyping = await read(page);
try { assert.equal(afterTyping, "one visible line\nsecond line", "Typing after Shift+Enter must stay after the newline"); }
catch (error) { visualFailures.push({ route, message: error.message }); }
assert.equal(posts.length, 0, "Shift+Enter is a newline, never submit");
await fill(page, "composer fallback"); await blur(page);
await syntheticDrop(page, ["/synthetic/default.pdf"]);
await expectValue(page, "composer fallback `/synthetic/default.pdf`");
// Small viewport: wrapped multiline content stays scrollable inside its editor.
await page.setViewport({ width: 900, height: 520 });
await fill(page, (`${token} a long synthetic line wrapping at narrow widths.\n`).repeat(24));
const sizing = await page.$eval(inputSelector, element => ({ height: element.clientHeight, scrollHeight: element.scrollHeight, overflowY: getComputedStyle(element).overflowY, bodyWidth: document.body.scrollWidth, viewport: innerWidth }));
assert.ok(sizing.scrollHeight > sizing.height && sizing.height > 0, JSON.stringify(sizing));
assert.ok(["auto", "scroll"].includes(sizing.overflowY));
assert.ok(sizing.bodyWidth <= sizing.viewport + 1, JSON.stringify(sizing));
const screenshot = path.join(home, `chrome-${route === "/" ? "home" : "new"}.png`); await page.screenshot({ path: screenshot }); screenshots.push({ surface: route, bytes: fs.statSync(screenshot).size, sizing });
await page.setViewport({ width: 1280, height: 900 });
checks.push(`${route}: native typing/chips/arrows/deletion/undo-redo/full-source clipboard/plaintext paste/caret drop/newlines/wrap-scroll`);
}
// Only an explicit Enter may initiate a send; agent endpoint is refused.
await fill(page, "explicit synthetic send `/synthetic/one.pdf`");
await page.keyboard.press("Enter");
await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname));
await new Promise((resolve, reject) => {
const deadline = Date.now() + 15000;
const inspect = () => { if (posts.length) resolve(); else if (Date.now() > deadline) reject(new Error("Explicit Enter did not send")); else setTimeout(inspect, 50); }; inspect();
});
assert.equal(posts.length, 1); assert.equal(posts[0].body.input.text, "explicit synthetic send `/synthetic/one.pdf`");
checks.push("Explicit Enter submitted exactly one full-source prompt; endpoint intercepted/refused");
// These raw JSON fixtures are synthetic native records, never store imports.
const reading = '[data-testid="reading-thinking-field"] [data-file-path-input]';
const question = '[data-testid="thinking-field"] [data-file-path-input]';
const practice = '[data-testid="practice-thinking-field"] [data-file-path-input]';
await open(page, "/chat/path_teach");
await page.waitForSelector('[data-testid="teach-check-reveal"]', { visible: true });
assert.equal(await page.$eval('[data-testid="user-message"] [role="img"]', chip => chip.title), "/synthetic/transcript notes.pdf");
assert.equal(await page.$eval('[data-testid="user-message"] [role="img"]', chip => chip.textContent), "transcript notes.pdf");
checks.push("Seeded sent learner message renders a filename chip with full-path title; raw record retains quoted full path");
await blur(page); await syntheticDrop(page, ["/synthetic/reading.pdf"]);
await expectValue(page, "`/synthetic/reading.pdf`", reading);
assert.equal(await read(page, question), "");
checks.push("Unrevealed teaching check: unfocused drop lands in reading-thinking via dictation registry");
await page.locator('[data-testid="teach-check-reveal"]').click();
await page.waitForSelector(question, { visible: true });
await fill(page, "AB", reading); await caret(page, 1, reading);
await syntheticDrop(page, ["/synthetic/focused-reading.pdf"]);
await expectValue(page, "A `/synthetic/focused-reading.pdf` B", reading);
assert.equal(await read(page, question), "", "focused reading field overrides opened question default");
await blur(page); await syntheticDrop(page, ["/synthetic/question.pdf"]);
await expectValue(page, "`/synthetic/question.pdf`", question);
checks.push("Opened teaching check: current reading focus wins; otherwise default routes to question-thinking");
if (await foldDock(page, '[data-testid="question-dock-bar"]')) {
await blur(page); await syntheticDrop(page, ["/synthetic/collapsed-question.pdf"]);
await expectValue(page, "`/synthetic/question.pdf` `/synthetic/collapsed-question.pdf`", question);
await page.locator('[data-testid="question-dock-bar"]').click();
await page.waitForSelector(question, { visible: true });
assert.equal(await read(page, question), "`/synthetic/question.pdf` `/synthetic/collapsed-question.pdf`");
checks.push("Collapsed revealed teaching dock keeps question-thinking claim/state through expansion");
}
await open(page, "/chat/path_probe"); await page.waitForSelector(question, { visible: true });
await blur(page); await syntheticDrop(page, ["/synthetic/probe.pdf"]);
await expectValue(page, "`/synthetic/probe.pdf`", question);
checks.push("Probe-phase pending question default routes to question-thinking");
await open(page, "/chat/path_practice"); await page.waitForSelector(practice, { visible: true });
await blur(page); await syntheticDrop(page, ["/synthetic/practice.pdf"]);
await expectValue(page, "`/synthetic/practice.pdf`", practice);
checks.push("Expanded practice thinking is the unfocused drop default");
if (await foldDock(page, '[data-testid="practice-dock-bar"]')) {
await blur(page); await syntheticDrop(page, ["/synthetic/collapsed-practice.pdf"]);
// Practice keeps the owning hook/state mounted, but hides its field
// by returning the bar. Inspect the controlled text after expansion.
assert.equal(await page.$('[role="status"]'), null, "successful attachment has no confirmation bubble");
await page.locator('[data-testid="practice-dock-bar"]').click();
await page.waitForSelector(practice, { visible: true });
await expectValue(page, "`/synthetic/practice.pdf` `/synthetic/collapsed-practice.pdf`", practice);
checks.push("Collapsed practice thinking keeps its claim/state through expansion without submission");
}
assert.equal(posts.length, 1, "all claim/drop cases stage text only");
}
// Actual Electron main + actual sandboxed preload. Temporary launcher sets
// userData BEFORE production main appends its -dev suffix or takes a lock.
const debugPort = await freePort();
const launcher = path.join(home, "electron-launcher.cjs");
fs.mkdirSync(path.join(home, "desktop-profile"), { recursive: true });
fs.mkdirSync(path.join(home, "desktop-profile-dev"), { recursive: true });
fs.writeFileSync(launcher, `const {app,session}=require('electron');\napp.setPath('home',${JSON.stringify(home)});\napp.setPath('userData',${JSON.stringify(path.join(home, "desktop-profile"))});\napp.commandLine.appendSwitch('remote-debugging-port',${JSON.stringify(String(debugPort))});\napp.commandLine.appendSwitch('disable-background-networking');\napp.commandLine.appendSwitch('disable-sync');\napp.whenReady().then(()=>session.defaultSession.webRequest.onBeforeRequest((details,cb)=>{let u;try{u=new URL(details.url)}catch{return cb({cancel:true})}cb({cancel:!(${JSON.stringify(origin)}===u.origin||(u.protocol==='ws:'&&u.host===${JSON.stringify(new URL(origin).host)})||['about:','data:','devtools:'].includes(u.protocol))});}));\nrequire(${JSON.stringify(path.join(root, "electron", "main.cjs"))});\nconsole.log('LH_FILE_PATH_PROFILE '+JSON.stringify({home:app.getPath('home'),userData:app.getPath('userData')}));\n`);
const electron = spawn(require("electron"), [launcher], { cwd: root, env: { ...env, LH_DEV_URL: origin }, stdio: ["ignore", "pipe", "pipe"] }); children.push(electron);
const captureElectron = data => { electronLogs = (electronLogs + data).slice(-24000); }; electron.stdout.on("data", captureElectron); electron.stderr.on("data", captureElectron);
await startup(electron, () => electronLogs, /DevTools listening on (ws:\/\/[^\s]+)/, 45000);
const profile = JSON.parse(electronLogs.match(/LH_FILE_PATH_PROFILE ([^\r\n]+)/)[1]);
assert.equal(profile.home, home); assert.equal(profile.userData, path.join(home, "desktop-profile-dev"));
const desktopLaunch = { executable: require("electron"), args: [launcher], LH_DEV_URL: origin, HOME: home, userDataBeforeMain: path.join(home, "desktop-profile"), actualProfile: profile, cleanup: "all temporary profiles/data/fixtures removed on exit" };
const endpoint = electronLogs.match(/DevTools listening on (ws:\/\/[^\s]+)/)[1];
const desktop = await puppeteer.connect({ browserWSEndpoint: endpoint }); browsers.push(desktop);
const desktopPage = await desktop.waitForTarget(target => target.type() === "page").then(target => target.page());
await instrument(desktopPage); page = desktopPage;
await open(desktopPage, "/chat/new");
assert.equal(await desktopPage.evaluate(() => typeof window.lh?.getPathForFile), "function");
const fixturePath = path.join(home, "actual OS-backed α `notes`.pdf"); fs.writeFileSync(fixturePath, "%PDF-1.7\nsynthetic offline fixture\n");
await desktopPage.evaluate(() => { const input = document.createElement("input"); input.type = "file"; input.id = "os-backed-fixture"; document.body.append(input); });
await (await desktopPage.$("#os-backed-fixture")).uploadFile(fixturePath);
const resolved = await desktopPage.evaluate(() => {
const input = document.querySelector("#os-backed-fixture");
return { count: input.files.length, actual: window.lh.getPathForFile(input.files[0]), virtual: window.lh.getPathForFile(new File(["virtual"], "/not/a/backing/path.pdf")) };
});
assert.equal(resolved.count, 1); assert.equal(resolved.actual, fixturePath); assert.equal(resolved.virtual, "");
await fill(desktopPage, "readthis"); await caret(desktopPage, 4);
const beforeDesktopDrop = posts.length;
assert.equal(await desktopPage.evaluate(() => {
const file = document.querySelector("#os-backed-fixture").files[0];
for (const method of ["text", "arrayBuffer", "stream"]) Object.defineProperty(file, method, { value() { throw new Error("No file content read"); } });
const transfer = new DataTransfer(); transfer.items.add(file);
const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer });
document.querySelector('[data-testid="file-path-drop-zone"]').dispatchEvent(event); return event.defaultPrevented;
}), true);
const expectedQuoted = "``" + fixturePath + "``";
await expectValue(desktopPage, `read ${expectedQuoted} this`);
assert.equal(await desktopPage.$eval('[data-file-path-input] [role="img"]', chip => chip.title), fixturePath);
assert.equal(posts.length, beforeDesktopDrop, "actual OS-backed desktop drop must stage only");
assert.equal(await desktopPage.$('[role="status"]'), null, "successful desktop attachment has no confirmation bubble");
assert.ok(fs.existsSync(fixturePath));
assert.deepEqual(fs.readdirSync(path.join(home, "data")).filter(name => /upload|attachment/i.test(name)), []);
checks.push("Actual Electron production preload: Puppeteer-selected OS-backed File resolves exact Unicode/backtick full path; virtual File returns empty; real DataTransfer drop inserts at caret without submit or file reads");
assert.deepEqual(errors, []);
console.log(JSON.stringify({ checks, visualFailures, coverageFailures, screenshots, desktopLaunch, postsIntercepted: posts.length, blockedExternalRequests: blocked, isolation: "temporary HOME/data/auth/browser+Electron profiles; synthetic catalog; agent POSTs refused; no providers", origin }, null, 2));
assert.deepEqual(visualFailures, [], "Visible trailing-newline/caret projection must pass");
assert.deepEqual(coverageFailures, [], "Required collapsed target coverage must run");
} catch (error) {
console.error(error);
console.error(JSON.stringify({ checks, visualFailures, coverageFailures, errors, consoleErrors, networkFailures: networkFailures.slice(-30), posts, origin, url: page?.url(), dom: await page?.evaluate(() => ({ inputs: [...document.querySelectorAll('[data-file-path-input]')].map(element => ({ placeholder: element.getAttribute("data-placeholder"), text: element.textContent, html: element.innerHTML.slice(0, 1200) })), text: document.body.innerText.slice(-2600) })).catch(() => null), serverLogs, electronLogs }, null, 2));
process.exitCode = 1;
} finally {
shuttingDown = true;
for (const browser of browsers.reverse()) { try { await browser.close(); } catch {} }
for (const child of children.reverse()) await stop(child);
fs.rmSync(home, { recursive: true, force: true });
}
+228
View File
@@ -0,0 +1,228 @@
/**
* OFFLINE image runtime gate: exercise the shipped Photon/WASM resize core and
* actual worker, with file modules confined to the standalone payload. No app,
* provider, canvas, or network needed; a tiny synthetic PNG is generated here.
*
* node scripts/smoke-image-runtime.mjs [--standalone <dir>] [--runtime <Node/Electron>]
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Worker } from "node:worker_threads";
import { deflateSync } from "node:zlib";
import { formatProbeError, probeEnv } from "./smoke-demo-runtime.mjs";
export function verifyImageRuntime(standalone, runtime = process.execPath) {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-image-runtime-"));
try {
fs.writeFileSync(path.join(home, "auth.json"), "{}\n", { mode: 0o600 });
const result = spawnSync(runtime, [fileURLToPath(import.meta.url), "--probe", path.resolve(standalone)], {
cwd: home,
env: { ...probeEnv(process.env, home), ELECTRON_RUN_AS_NODE: "1" },
encoding: "utf8", timeout: 30_000, killSignal: "SIGKILL", maxBuffer: 128 * 1024,
});
if (result.status !== 0 || result.error) {
throw new Error(`image runtime probe failed (${result.error?.message ?? result.status}):\n${result.stdout ?? ""}${result.stderr ?? ""}`);
}
assert.match(result.stdout, /Photon in-process and worker resizing passed/, "probe exited without success marker");
process.stdout.write(result.stdout);
} finally { fs.rmSync(home, { recursive: true, force: true }); }
}
// Self-contained so the eval worker can install the SAME guards using only
// builtins, before importing any payload module. Module hooks do not otherwise
// propagate from the dedicated probe process into Worker threads.
function installGuards(require, standalone) {
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { fileURLToPath } = require("node:url");
const { registerHooks, syncBuiltinESMExports } = require("node:module");
const root = fs.realpathSync(standalone);
const inside = (file) => {
const real = fs.realpathSync(file);
const relative = path.relative(root, real);
assert.ok(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative),
`dependency escaped standalone payload: ${file}`);
return real;
};
const photonRoot = path.join(root, "node_modules/@silvia-odwyer/photon-node");
const manifest = JSON.parse(fs.readFileSync(inside(path.join(photonRoot, "package.json")), "utf8"));
assert.equal(manifest.version, "0.3.4", "unexpected Photon engine version");
const photonEntry = inside(path.join(photonRoot, "photon_rs.js"));
const photonWasm = inside(path.join(photonRoot, "photon_rs_bg.wasm"));
assert.ok(fs.statSync(photonWasm).size > 0, "empty Photon WASM asset");
let attempts = 0;
const deny = () => { attempts++; throw new Error("OFFLINE image gate: network forbidden"); };
globalThis.fetch = deny;
globalThis.WebSocket = class { constructor() { deny(); } };
for (const [name, methods] of [
["http", ["request", "get"]], ["https", ["request", "get"]],
["net", ["connect", "createConnection"]], ["tls", ["connect"]],
["dgram", ["createSocket"]],
["dns", ["lookup", "resolve", "resolve4", "resolve6", "resolveAny", "reverse"]],
["dns/promises", ["lookup", "resolve", "resolve4", "resolve6", "resolveAny", "reverse"]],
]) {
const module = require(`node:${name}`);
for (const method of methods) module[method] = deny;
}
require("node:net").Socket.prototype.connect = deny;
const loaded = new Set();
const wasmReads = new Set();
const originalRead = fs.readFileSync;
fs.readFileSync = function (file, ...args) {
const filename = file instanceof URL ? fileURLToPath(file) : file;
const wasmFile = typeof filename === "string" && filename.endsWith(".wasm") ? inside(filename) : null;
const bytes = originalRead.call(this, file, ...args);
if (wasmFile) wasmReads.add(wasmFile);
return bytes;
};
syncBuiltinESMExports();
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
const result = nextResolve(specifier, context);
if (result.url.startsWith("file:")) loaded.add(inside(fileURLToPath(result.url)));
else assert.ok(result.url.startsWith("node:"), `unexpected dependency URL: ${result.url}`);
return result;
},
});
return {
report() {
assert.equal(attempts, 0, "image runtime attempted network access");
assert.ok(loaded.has(photonEntry), "Photon JS was not loaded from the payload");
assert.ok(wasmReads.has(photonWasm), "Photon WASM was not loaded from the payload");
return { modules: loaded.size, wasmFiles: [...wasmReads], networkAttempts: attempts };
},
cleanup() {
hooks.deregister();
fs.readFileSync = originalRead;
syncBuiltinESMExports();
},
};
}
/** Pure Node 2×2 RGBA PNG, with valid PNG CRCs and zlib image data. */
function fixturePng() {
const chunk = (type, data) => {
const buffer = Buffer.alloc(data.length + 12);
buffer.writeUInt32BE(data.length);
buffer.write(type, 4, "ascii");
data.copy(buffer, 8);
let crc = ~0;
for (const byte of buffer.subarray(4, -4)) {
crc ^= byte;
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
}
buffer.writeUInt32BE(~crc >>> 0, buffer.length - 4);
return buffer;
};
const header = Buffer.alloc(13);
header.writeUInt32BE(2, 0);
header.writeUInt32BE(2, 4);
header[8] = 8; // bit depth
header[9] = 6; // RGBA
const pixels = Buffer.from([
0, 255, 0, 0, 255, 0, 255, 0, 255,
0, 0, 0, 255, 255, 255, 255, 255, 255,
]);
return Buffer.concat([Buffer.from("89504e470d0a1a0a", "hex"), chunk("IHDR", header), chunk("IDAT", deflateSync(pixels)), chunk("IEND", Buffer.alloc(0))]);
}
function assertResized(result) {
assert.ok(result, "resize returned null (missing/broken Photon must not silently pass)");
assert.equal(result.originalWidth, 2);
assert.equal(result.originalHeight, 2);
assert.equal(result.width, 1);
assert.equal(result.height, 1);
assert.equal(result.wasResized, true);
assert.equal(result.mimeType, "image/png");
const png = Buffer.from(result.data, "base64");
assert.equal(png.subarray(0, 8).toString("hex"), "89504e470d0a1a0a");
assert.equal(png.readUInt32BE(16), 1);
assert.equal(png.readUInt32BE(20), 1);
}
async function probeWorker(root, inputBytes, options, responseType) {
const readyType = "lh:image-runtime-ready";
const worker = new Worker(`
const { parentPort, workerData } = require("node:worker_threads");
const guards = (${installGuards.toString()})(require, workerData.root);
const post = parentPort.postMessage.bind(parentPort);
parentPort.postMessage = (message, ...args) => {
if (message?.type === workerData.responseType) {
post({ ...message, runtimeProof: guards.report() }, ...args);
} else post(message, ...args);
};
void import(workerData.entry).then(() => post({ type: workerData.readyType }));
`, {
eval: true,
workerData: { root, responseType, readyType, entry: pathToFileURL(path.join(root, "src/agent/image-processing/resize-worker.mjs")).href },
});
try {
return await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("image resize worker timed out")), 10_000);
const fail = (error) => { clearTimeout(timer); reject(error); };
let ready = false;
worker.once("error", fail);
worker.once("exit", (code) => fail(new Error(`image resize worker exited before response: ${code}`)));
worker.on("message", (message) => {
try {
if (message?.type === readyType) {
assert.equal(ready, false, "duplicate worker READY");
ready = true;
worker.postMessage({ inputBytes, mimeType: "image/png", options });
} else if (message?.type === responseType) {
assert.equal(ready, true, "worker replied before its import completed");
assert.equal(message.error, undefined, `worker resize failed: ${message.error}`);
assertResized(message.result);
assert.ok(message.runtimeProof?.modules > 0, "missing worker module-confinement proof");
assert.equal(message.runtimeProof.networkAttempts, 0);
assert.equal(message.runtimeProof.wasmFiles.length, 1);
clearTimeout(timer);
resolve(message.runtimeProof);
}
} catch (error) { fail(error); }
});
});
} finally { await worker.terminate(); }
}
async function probe(standalone) {
const root = fs.realpathSync(standalone);
const guards = installGuards(createRequire(import.meta.url), root);
try {
const entry = pathToFileURL(path.join(root, "src/agent/image-processing/resize-core.mjs"));
const { resizeImageInProcess, IMAGE_RESIZE_WORKER_RESPONSE_TYPE } = await import(entry.href);
assert.equal(typeof resizeImageInProcess, "function");
assert.equal(IMAGE_RESIZE_WORKER_RESPONSE_TYPE, "pi:image-resize-response");
const input = fixturePng();
const options = { maxWidth: 1, maxHeight: 1, maxBytes: 1024 * 1024 };
assertResized(await resizeImageInProcess(input, "image/png", options));
const inProcess = guards.report();
console.log(`[smoke-image-runtime] shipped resize core: PNG 2×2 → 1×1; ${inProcess.modules} modules and Photon WASM confined to ${root}`);
const worker = await probeWorker(root, input, options, IMAGE_RESIZE_WORKER_RESPONSE_TYPE);
guards.report();
console.log(`[smoke-image-runtime] shipped resize worker: READY then tagged PNG 2×2 → 1×1; ${worker.modules} modules and Photon WASM confined to ${root}`);
console.log("[smoke-image-runtime] Photon in-process and worker resizing passed; zero network attempts");
} finally { guards.cleanup(); }
}
async function main() {
const args = process.argv.slice(2);
if (args[0] === "--probe") return probe(args[1]);
const value = (name, fallback) => {
const index = args.indexOf(name);
if (index < 0) return fallback;
assert.ok(args[index + 1] && !args[index + 1].startsWith("--"), `${name} requires a path`);
return path.resolve(args[index + 1]);
};
verifyImageRuntime(value("--standalone", path.resolve(import.meta.dirname, "../.next/standalone")), value("--runtime", process.execPath));
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => { console.error(`[smoke-image-runtime] FAIL: ${formatProbeError(error)}`); process.exitCode = 1; });
}
+10 -7
View File
@@ -109,14 +109,14 @@ try {
// Release synthetic streams from prior cases rather than pin slots. // Release synthetic streams from prior cases rather than pin slots.
await Promise.all(held.splice(0).map(request => request.abort("aborted").catch(() => {}))); await Promise.all(held.splice(0).map(request => request.abort("aborted").catch(() => {})));
await page.goto(origin + route, { waitUntil: "domcontentloaded", timeout: 120000 }); await page.goto(origin + route, { waitUntil: "domcontentloaded", timeout: 120000 });
await page.waitForSelector(route === "/" ? "textarea" : '[data-testid="composer"] textarea'); await page.waitForSelector(route === "/" ? "[data-file-path-input]" : '[data-testid="composer"] [data-file-path-input]');
await page.waitForFunction(() => !document.querySelector('[data-testid="setup-cta"]') && !document.querySelector('[data-testid="setup-cta-keys"]')); await page.waitForFunction(() => !document.querySelector('[data-testid="setup-cta"]') && !document.querySelector('[data-testid="setup-cta-keys"]'));
} }
async function fill(text) { async function fill(text) {
await page.evaluate(value => { await page.evaluate(value => {
const textarea = document.querySelector('[data-testid="composer"] textarea') || document.querySelector("textarea"); const input = document.querySelector('[data-testid="composer"] [data-file-path-input]') || document.querySelector("[data-file-path-input]");
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value").set.call(textarea, value); input.textContent = value;
textarea.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("input", { bubbles: true }));
}, text); }, text);
} }
async function submit() { async function submit() {
@@ -127,7 +127,10 @@ try {
await page.locator('button[aria-label="Start session"], [data-testid="composer"] button[aria-label="Send"]').click(); await page.locator('button[aria-label="Start session"], [data-testid="composer"] button[aria-label="Send"]').click();
} }
async function stash(id) { return page.evaluate(chat => sessionStorage.getItem(`lh.openingPrompt.${chat}`), id); } async function stash(id) { return page.evaluate(chat => sessionStorage.getItem(`lh.openingPrompt.${chat}`), id); }
async function assertDraft(text) { await page.waitForFunction(value => document.querySelector('[data-testid="composer"] textarea')?.value === value, {}, text); } async function assertDraft(text) { await page.waitForFunction(value => {
const input = document.querySelector('[data-testid="composer"] [data-file-path-input]');
return input && [...input.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join("") === value;
}, {}, text); }
const long = "A full synthetic syllabus — 世界 & ? #. ".repeat(1200).trim(); const long = "A full synthetic syllabus — 世界 & ? #. ".repeat(1200).trim();
for (const route of ["/", "/chat/new"]) { for (const route of ["/", "/chat/new"]) {
for (const text of ["a short synthetic topic", long]) { for (const text of ["a short synthetic topic", long]) {
@@ -163,7 +166,7 @@ try {
await fill(long); await submit(); await fill(long); await submit();
await page.waitForSelector('[role="alert"]'); await page.waitForSelector('[role="alert"]');
assert.equal(new URL(page.url()).pathname, route); assert.equal(new URL(page.url()).pathname, route);
assert.equal(await page.$eval("textarea", element => element.value), long); assert.equal(await page.$eval("[data-file-path-input]", element => [...element.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join("")), long);
assert.equal(posts.length, sendCount); assert.equal(posts.length, sendCount);
assert.equal(creates.length, createCount + 1); assert.equal(creates.length, createCount + 1);
await page.evaluate(() => { window.failOpeningStorage = false; }); await page.evaluate(() => { window.failOpeningStorage = false; });
@@ -239,7 +242,7 @@ try {
console.log(JSON.stringify({ checks, pageErrors: errors, isolation: "synthetic catalog/POST+streams; empty auth; no providers", origin }, null, 2)); console.log(JSON.stringify({ checks, pageErrors: errors, isolation: "synthetic catalog/POST+streams; empty auth; no providers", origin }, null, 2));
} catch (err) { } catch (err) {
console.error(err); console.error(err);
console.error(JSON.stringify({ checks, errors, url: page?.url(), dom: await page?.evaluate(() => ({ textareas: [...document.querySelectorAll('textarea')].map(e => ({ length: e.value.length, placeholder: e.placeholder })), buttons: [...document.querySelectorAll('button[aria-label="Send"], button[aria-label="Start session"]')].map(e => ({ disabled: e.disabled, label: e.getAttribute('aria-label') })), alerts: [...document.querySelectorAll('[role="alert"]')].map(e => e.textContent), failStorage: window.failOpeningStorage, text: document.body.innerText.slice(-1200) })).catch(() => null) }, null, 2)); console.error(JSON.stringify({ checks, errors, url: page?.url(), dom: await page?.evaluate(() => ({ inputs: [...document.querySelectorAll('[data-file-path-input]')].map(e => ({ length: [...e.childNodes].map(n => n.nodeType === 1 ? n.getAttribute('data-file-path-source') ?? n.textContent : n.textContent).join('').length, placeholder: e.getAttribute('data-placeholder') })), buttons: [...document.querySelectorAll('button[aria-label="Send"], button[aria-label="Start session"]')].map(e => ({ disabled: e.disabled, label: e.getAttribute('aria-label') })), alerts: [...document.querySelectorAll('[role="alert"]')].map(e => e.textContent), failStorage: window.failOpeningStorage, text: document.body.innerText.slice(-1200) })).catch(() => null) }, null, 2));
console.error(logs); process.exitCode = 1; console.error(logs); process.exitCode = 1;
} finally { } finally {
await browser?.close(); await browser?.close();
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Mario Zechner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1
View File
@@ -0,0 +1 @@
export function convertImageBytesToPng(bytes: Uint8Array): Promise<Uint8Array | null>;
+25
View File
@@ -0,0 +1,25 @@
// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-convert.js (MIT).
// Only inline-image conversion is retained; terminal/Kitty registration is not used.
import { applyExifOrientation } from "./exif-orientation.mjs";
import { loadPhoton } from "./photon.mjs";
function encodePng(photon, bytes) {
try {
const rawImage = photon.PhotonImage.new_from_byteslice(bytes);
const image = applyExifOrientation(photon, rawImage, bytes);
if (image !== rawImage) rawImage.free();
try {
return new Uint8Array(image.get_bytes());
} finally {
image.free();
}
} catch {
return null;
}
}
export async function convertImageBytesToPng(bytes) {
const photon = await loadPhoton();
if (!photon) return null;
return encodePng(photon, bytes);
}
@@ -0,0 +1,157 @@
// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/exif-orientation.js (MIT).
function readOrientationFromTiff(bytes, tiffStart) {
if (tiffStart + 8 > bytes.length)
return 1;
const byteOrder = (bytes[tiffStart] << 8) | bytes[tiffStart + 1];
const le = byteOrder === 0x4949;
const read16 = (pos) => {
if (le)
return bytes[pos] | (bytes[pos + 1] << 8);
return (bytes[pos] << 8) | bytes[pos + 1];
};
const read32 = (pos) => {
if (le)
return bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16) | (bytes[pos + 3] << 24);
return ((bytes[pos] << 24) | (bytes[pos + 1] << 16) | (bytes[pos + 2] << 8) | bytes[pos + 3]) >>> 0;
};
const ifdOffset = read32(tiffStart + 4);
const ifdStart = tiffStart + ifdOffset;
if (ifdStart + 2 > bytes.length)
return 1;
const entryCount = read16(ifdStart);
for (let i = 0; i < entryCount; i++) {
const entryPos = ifdStart + 2 + i * 12;
if (entryPos + 12 > bytes.length)
return 1;
if (read16(entryPos) === 0x0112) {
const value = read16(entryPos + 8);
return value >= 1 && value <= 8 ? value : 1;
}
}
return 1;
}
function findJpegTiffOffset(bytes) {
let offset = 2;
while (offset < bytes.length - 1) {
if (bytes[offset] !== 0xff)
return -1;
const marker = bytes[offset + 1];
if (marker === 0xff) {
offset++;
continue;
}
if (marker === 0xe1) {
if (offset + 4 >= bytes.length)
return -1;
const segmentStart = offset + 4;
if (segmentStart + 6 > bytes.length)
return -1;
if (hasExifHeader(bytes, segmentStart))
return segmentStart + 6;
}
if (offset + 4 > bytes.length)
return -1;
const length = (bytes[offset + 2] << 8) | bytes[offset + 3];
offset += 2 + length;
}
return -1;
}
function findWebpTiffOffset(bytes) {
let offset = 12;
while (offset + 8 <= bytes.length) {
const chunkId = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
const chunkSize = bytes[offset + 4] | (bytes[offset + 5] << 8) | (bytes[offset + 6] << 16) | (bytes[offset + 7] << 24);
const dataStart = offset + 8;
if (chunkId === "EXIF") {
if (dataStart + chunkSize > bytes.length)
return -1;
// Some WebP files have "Exif\0\0" prefix before the TIFF header
const tiffStart = chunkSize >= 6 && hasExifHeader(bytes, dataStart) ? dataStart + 6 : dataStart;
return tiffStart;
}
// RIFF chunks are padded to even size
offset = dataStart + chunkSize + (chunkSize % 2);
}
return -1;
}
function hasExifHeader(bytes, offset) {
return (bytes[offset] === 0x45 &&
bytes[offset + 1] === 0x78 &&
bytes[offset + 2] === 0x69 &&
bytes[offset + 3] === 0x66 &&
bytes[offset + 4] === 0x00 &&
bytes[offset + 5] === 0x00);
}
function getExifOrientation(bytes) {
let tiffOffset = -1;
// JPEG: starts with FF D8
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8) {
tiffOffset = findJpegTiffOffset(bytes);
}
// WebP: starts with RIFF....WEBP
else if (bytes.length >= 12 &&
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x57 &&
bytes[9] === 0x45 &&
bytes[10] === 0x42 &&
bytes[11] === 0x50) {
tiffOffset = findWebpTiffOffset(bytes);
}
if (tiffOffset === -1)
return 1;
return readOrientationFromTiff(bytes, tiffOffset);
}
function rotate90(photon, image, dstIndex) {
const w = image.get_width();
const h = image.get_height();
const src = image.get_raw_pixels();
const dst = new Uint8Array(src.length);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const srcIdx = (y * w + x) * 4;
const dstIdx = dstIndex(x, y, w, h) * 4;
dst[dstIdx] = src[srcIdx];
dst[dstIdx + 1] = src[srcIdx + 1];
dst[dstIdx + 2] = src[srcIdx + 2];
dst[dstIdx + 3] = src[srcIdx + 3];
}
}
return new photon.PhotonImage(dst, h, w);
}
// Flip orientations mutate in-place. Rotations return a new image (caller must free the old one if different).
export function applyExifOrientation(photon, image, originalBytes) {
const orientation = getExifOrientation(originalBytes);
if (orientation === 1)
return image;
switch (orientation) {
case 2:
photon.fliph(image);
return image;
case 3:
photon.fliph(image);
photon.flipv(image);
return image;
case 4:
photon.flipv(image);
return image;
case 5: {
const rotated = rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y));
photon.fliph(rotated);
return rotated;
}
case 6:
return rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y));
case 7: {
const rotated = rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y);
photon.fliph(rotated);
return rotated;
}
case 8:
return rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y);
default:
return image;
}
}
+59
View File
@@ -0,0 +1,59 @@
// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-process.js (MIT).
import { convertImageBytesToPng } from "./convert.mjs";
import { formatDimensionNote, resizeImage } from "./resize.ts";
import type { ProcessImageOptions, ProcessImageResult } from "./types.ts";
export { detectSupportedImageMimeType, detectSupportedImageMimeTypeFromFile } from "./mime.mjs";
export type { ModelImageResizeOptions, ProcessImageOptions, ProcessImageResult, ResizedImage, SupportedImageMimeType } from "./types.ts";
function baseMimeType(mimeType: string): string {
return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
}
function normalizeSupportedImageMimeType(mimeType: string): string | null {
switch (baseMimeType(mimeType)) {
case "image/png": return "image/png";
case "image/jpeg":
case "image/jpg": return "image/jpeg";
case "image/gif": return "image/gif";
case "image/webp": return "image/webp";
default: return null;
}
}
async function normalizeImage(bytes: Uint8Array, mimeType: string): Promise<{ bytes: Uint8Array; mimeType: string; convertedFrom?: string } | null> {
const normalizedMimeType = normalizeSupportedImageMimeType(mimeType);
if (normalizedMimeType) return { bytes, mimeType: normalizedMimeType };
const pngBytes = await convertImageBytesToPng(bytes);
if (!pngBytes) return null;
return { bytes: pngBytes, mimeType: "image/png", convertedFrom: baseMimeType(mimeType) };
}
function conversionHint(from: string | undefined, to: string): string | undefined {
if (!from || from === to) return undefined;
return `[Image converted from ${from} to ${to}.]`;
}
export async function processImage(bytes: Uint8Array, mimeType: string, options?: ProcessImageOptions): Promise<ProcessImageResult> {
const autoResizeImages = options?.autoResizeImages ?? true;
const normalized = await normalizeImage(bytes, mimeType);
if (!normalized) {
return { ok: false, message: "[Image omitted: could not be converted to a supported inline image format.]" };
}
if (autoResizeImages) {
const resized = await resizeImage(normalized.bytes, normalized.mimeType, options?.resizeOptions);
if (!resized) {
return { ok: false, message: "[Image omitted: could not be resized below the inline image size limit.]" };
}
const hints: string[] = [];
const convertedHint = conversionHint(normalized.convertedFrom, resized.mimeType);
if (convertedHint) hints.push(convertedHint);
const dimensionNote = formatDimensionNote(resized);
if (dimensionNote) hints.push(dimensionNote);
return { ok: true, data: resized.data, mimeType: resized.mimeType, hints };
}
const hints: string[] = [];
const convertedHint = conversionHint(normalized.convertedFrom, normalized.mimeType);
if (convertedHint) hints.push(convertedHint);
return { ok: true, data: Buffer.from(normalized.bytes).toString("base64"), mimeType: normalized.mimeType, hints };
}
+3
View File
@@ -0,0 +1,3 @@
import type { SupportedImageMimeType } from "./types.ts";
export function detectSupportedImageMimeType(buffer: Uint8Array): SupportedImageMimeType | null;
export function detectSupportedImageMimeTypeFromFile(filePath: string): Promise<SupportedImageMimeType | null>;
+110
View File
@@ -0,0 +1,110 @@
// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/mime.js (MIT).
import { open } from "node:fs/promises";
const IMAGE_TYPE_SNIFF_BYTES = 4100;
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
export function detectSupportedImageMimeType(buffer) {
if (startsWith(buffer, [0xff, 0xd8, 0xff])) {
return buffer[3] === 0xf7 ? null : "image/jpeg";
}
if (startsWith(buffer, PNG_SIGNATURE)) {
return isPng(buffer) && !isAnimatedPng(buffer) ? "image/png" : null;
}
if (startsWithAscii(buffer, 0, "GIF87a") || startsWithAscii(buffer, 0, "GIF89a")) {
return "image/gif";
}
if (startsWithAscii(buffer, 0, "RIFF") && startsWithAscii(buffer, 8, "WEBP")) {
return "image/webp";
}
if (startsWithAscii(buffer, 0, "BM") && isBmp(buffer)) {
return "image/bmp";
}
return null;
}
export async function detectSupportedImageMimeTypeFromFile(filePath) {
const fileHandle = await open(filePath, "r");
try {
const buffer = Buffer.alloc(IMAGE_TYPE_SNIFF_BYTES);
const { bytesRead } = await fileHandle.read(buffer, 0, IMAGE_TYPE_SNIFF_BYTES, 0);
return detectSupportedImageMimeType(buffer.subarray(0, bytesRead));
}
finally {
await fileHandle.close();
}
}
function isPng(buffer) {
return (buffer.length >= 16 && readUint32BE(buffer, PNG_SIGNATURE.length) === 13 && startsWithAscii(buffer, 12, "IHDR"));
}
function isAnimatedPng(buffer) {
let offset = PNG_SIGNATURE.length;
while (offset + 8 <= buffer.length) {
const chunkLength = readUint32BE(buffer, offset);
const chunkTypeOffset = offset + 4;
if (startsWithAscii(buffer, chunkTypeOffset, "acTL"))
return true;
if (startsWithAscii(buffer, chunkTypeOffset, "IDAT"))
return false;
const nextOffset = offset + 8 + chunkLength + 4;
if (nextOffset <= offset || nextOffset > buffer.length)
return false;
offset = nextOffset;
}
return false;
}
function isBmp(buffer) {
if (buffer.length < 26)
return false;
const declaredFileSize = readUint32LE(buffer, 2);
const pixelDataOffset = readUint32LE(buffer, 10);
const dibHeaderSize = readUint32LE(buffer, 14);
if (declaredFileSize !== 0 && declaredFileSize < 26)
return false;
if (pixelDataOffset < 14 + dibHeaderSize)
return false;
if (declaredFileSize !== 0 && pixelDataOffset >= declaredFileSize)
return false;
let colorPlanes;
let bitsPerPixel;
if (dibHeaderSize === 12) {
colorPlanes = readUint16LE(buffer, 22);
bitsPerPixel = readUint16LE(buffer, 24);
}
else if (dibHeaderSize >= 40 && dibHeaderSize <= 124) {
if (buffer.length < 30)
return false;
colorPlanes = readUint16LE(buffer, 26);
bitsPerPixel = readUint16LE(buffer, 28);
}
else {
return false;
}
return colorPlanes === 1 && [1, 4, 8, 16, 24, 32].includes(bitsPerPixel);
}
function readUint16LE(buffer, offset) {
return (buffer[offset] ?? 0) + ((buffer[offset + 1] ?? 0) << 8);
}
function readUint32BE(buffer, offset) {
return ((buffer[offset] ?? 0) * 0x1000000 +
((buffer[offset + 1] ?? 0) << 16) +
((buffer[offset + 2] ?? 0) << 8) +
(buffer[offset + 3] ?? 0));
}
function readUint32LE(buffer, offset) {
return ((buffer[offset] ?? 0) +
((buffer[offset + 1] ?? 0) << 8) +
((buffer[offset + 2] ?? 0) << 16) +
(buffer[offset + 3] ?? 0) * 0x1000000);
}
function startsWith(buffer, bytes) {
if (buffer.length < bytes.length)
return false;
return bytes.every((byte, index) => buffer[index] === byte);
}
function startsWithAscii(buffer, offset, text) {
if (buffer.length < offset + text.length)
return false;
for (let index = 0; index < text.length; index++) {
if (buffer[offset + index] !== text.charCodeAt(index))
return false;
}
return true;
}
+21
View File
@@ -0,0 +1,21 @@
// Adapted from @earendil-works/pi-coding-agent 1.1.0, dist/utils/photon.js (MIT).
// Source-vs-runtime adaptation: this app runs in Node/Electron, not a Bun
// compiled executable. Photon loads its colocated WASM normally; no global fs
// monkeypatch or executable-relative WASM fallback is needed.
let photonModule = null;
let loadPromise = null;
export async function loadPhoton() {
if (photonModule) return photonModule;
if (loadPromise) return loadPromise;
loadPromise = (async () => {
try {
photonModule = await import("@silvia-odwyer/photon-node");
return photonModule;
} catch {
photonModule = null;
return photonModule;
}
})();
return loadPromise;
}
@@ -0,0 +1,10 @@
import type { ModelImageResizeOptions, ResizedImage } from "./types.ts";
export const IMAGE_RESIZE_WORKER_RESPONSE_TYPE: "pi:image-resize-response";
export function resizeImageInProcess(inputBytes: Uint8Array, mimeType: string, options?: ModelImageResizeOptions): Promise<ResizedImage | null>;
export type ResizeImageWorkerResponse = {
type: typeof IMAGE_RESIZE_WORKER_RESPONSE_TYPE;
result: ResizedImage | null;
} | {
type: typeof IMAGE_RESIZE_WORKER_RESPONSE_TYPE;
error: string;
};
+130
View File
@@ -0,0 +1,130 @@
// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-resize-core.js (MIT).
// Runtime .mjs resources stay unbundled for Next's Node/Electron worker packaging.
import { applyExifOrientation } from "./exif-orientation.mjs";
import { loadPhoton } from "./photon.mjs";
/**
* Tag on image resize worker replies. Node can post its own messages on the worker channel
* (for example `{ "watch:require": [...] }` under `node --watch`), so replies must be identifiable.
*/
export const IMAGE_RESIZE_WORKER_RESPONSE_TYPE = "pi:image-resize-response";
// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit.
const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;
const DEFAULT_OPTIONS = {
maxWidth: 2000,
maxHeight: 2000,
maxBytes: DEFAULT_MAX_BYTES,
jpegQuality: 80,
};
function encodeCandidate(buffer, mimeType) {
const data = Buffer.from(buffer).toString("base64");
return {
data,
encodedSize: Buffer.byteLength(data, "utf-8"),
mimeType,
};
}
/**
* Resize an image to fit within the specified max dimensions and encoded file size.
* Returns null if the image cannot be resized below maxBytes.
*
* Uses Photon (Rust/WASM) for image processing. If Photon is not available,
* returns null.
*
* Strategy for staying under maxBytes:
* 1. First resize to maxWidth/maxHeight
* 2. Try PNG first, then JPEG qualities in insertion order (first under cap wins)
* 3. If still too large, try the remaining JPEG qualities
* 4. If still too large, progressively reduce dimensions until 1x1
*/
export async function resizeImageInProcess(inputBytes, mimeType, options) {
const opts = { ...DEFAULT_OPTIONS, ...options };
const inputBase64Size = Math.ceil(inputBytes.byteLength / 3) * 4;
const photon = await loadPhoton();
if (!photon) {
return null;
}
let image;
try {
const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes);
image = applyExifOrientation(photon, rawImage, inputBytes);
if (image !== rawImage)
rawImage.free();
const originalWidth = image.get_width();
const originalHeight = image.get_height();
const format = mimeType.split("/")[1] ?? "png";
// Check if already within all limits (dimensions AND encoded size)
if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) {
return {
data: Buffer.from(inputBytes).toString("base64"),
mimeType: mimeType || `image/${format}`,
originalWidth,
originalHeight,
width: originalWidth,
height: originalHeight,
wasResized: false,
};
}
// Calculate initial dimensions respecting max limits
let targetWidth = originalWidth;
let targetHeight = originalHeight;
if (targetWidth > opts.maxWidth) {
targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
targetWidth = opts.maxWidth;
}
if (targetHeight > opts.maxHeight) {
targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
targetHeight = opts.maxHeight;
}
function tryEncodings(width, height, jpegQualities) {
const resized = photon.resize(image, width, height, photon.SamplingFilter.Lanczos3);
try {
const candidates = [encodeCandidate(resized.get_bytes(), "image/png")];
for (const quality of jpegQualities) {
candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg"));
}
return candidates;
}
finally {
resized.free();
}
}
const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40]));
let currentWidth = targetWidth;
let currentHeight = targetHeight;
while (true) {
const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps);
for (const candidate of candidates) {
if (candidate.encodedSize < opts.maxBytes) {
return {
data: candidate.data,
mimeType: candidate.mimeType,
originalWidth,
originalHeight,
width: currentWidth,
height: currentHeight,
wasResized: true,
};
}
}
if (currentWidth === 1 && currentHeight === 1) {
break;
}
const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75));
const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75));
if (nextWidth === currentWidth && nextHeight === currentHeight) {
break;
}
currentWidth = nextWidth;
currentHeight = nextHeight;
}
return null;
}
catch {
return null;
}
finally {
if (image) {
image.free();
}
}
}
@@ -0,0 +1,32 @@
// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-resize-worker.js (MIT).
import { parentPort } from "node:worker_threads";
import { IMAGE_RESIZE_WORKER_RESPONSE_TYPE, resizeImageInProcess, } from "./resize-core.mjs";
function isResizeImageWorkerRequest(value) {
if (!value || typeof value !== "object")
return false;
const record = value;
return record.inputBytes instanceof Uint8Array && typeof record.mimeType === "string";
}
const port = parentPort;
if (!port) {
throw new Error("image resize worker requires parentPort");
}
port.once("message", (message) => {
void (async () => {
try {
if (!isResizeImageWorkerRequest(message)) {
throw new Error("Invalid image resize worker request");
}
const result = await resizeImageInProcess(message.inputBytes, message.mimeType, message.options);
const response = { type: IMAGE_RESIZE_WORKER_RESPONSE_TYPE, result };
port.postMessage(response);
}
catch (error) {
const response = {
type: IMAGE_RESIZE_WORKER_RESPONSE_TYPE,
error: error instanceof Error ? error.message : String(error),
};
port.postMessage(response);
}
})();
});
+65
View File
@@ -0,0 +1,65 @@
// Ported from @earendil-works/pi-coding-agent 1.1.0, dist/utils/image-resize.js (MIT).
import path from "node:path";
import { Worker } from "node:worker_threads";
import {
IMAGE_RESIZE_WORKER_RESPONSE_TYPE,
resizeImageInProcess,
type ResizeImageWorkerResponse,
} from "./resize-core.mjs";
import type { ModelImageResizeOptions, ResizedImage } from "./types.ts";
function isResizeImageWorkerResponse(value: unknown): value is ResizeImageWorkerResponse {
return value !== null && typeof value === "object" && "type" in value && value.type === IMAGE_RESIZE_WORKER_RESPONSE_TYPE;
}
async function resizeImageInWorker(workerPath: string, inputBytes: Uint8Array, mimeType: string, options?: ModelImageResizeOptions): Promise<ResizedImage | null> {
const worker = new Worker(workerPath);
try {
// Transfer a worker-owned copy so the caller's bytes remain intact.
const inputBytesForWorker = new Uint8Array(inputBytes);
return await new Promise((resolve, reject) => {
let settled = false;
const settle = (result: ResizedImage | null) => {
if (settled) return;
settled = true;
resolve(result);
};
const fail = (error: Error) => {
if (settled) return;
settled = true;
reject(error);
};
worker.on("message", (message: unknown) => {
// Ignore Node's own worker-channel messages (e.g. node --watch).
if (!isResizeImageWorkerResponse(message)) return;
if ("error" in message) fail(new Error(message.error));
else settle(message.result);
});
worker.once("error", fail);
worker.once("exit", (code) => {
if (!settled) fail(new Error(`Image resize worker exited with code ${code}`));
});
worker.postMessage({ inputBytes: inputBytesForWorker, mimeType, options }, [inputBytesForWorker.buffer]);
});
} finally {
void worker.terminate().catch(() => undefined);
}
}
export async function resizeImage(inputBytes: Uint8Array, mimeType: string, options?: ModelImageResizeOptions): Promise<ResizedImage | null> {
// Source-vs-runtime adaptation: Next bundles the TS orchestration, but traces
// unbundled .mjs worker/core/EXIF/Photon resources at this cwd-relative path.
// The packaged server's cwd is the standalone root. No Bun path is needed.
const workerPath = path.join(process.cwd(), "src/agent/image-processing/resize-worker.mjs");
try {
return await resizeImageInWorker(workerPath, inputBytes, mimeType, options);
} catch {
return resizeImageInProcess(inputBytes, mimeType, options);
}
}
export function formatDimensionNote(result: ResizedImage): string | undefined {
if (!result.wasResized) return undefined;
const scale = result.originalWidth / result.width;
return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;
}
+35
View File
@@ -0,0 +1,35 @@
// Port baseline: @earendil-works/pi-coding-agent 1.1.0 image utilities (MIT).
/** Native resize overrides, supplied by the caller for the selected model. */
export interface ModelImageResizeOptions {
maxWidth?: number;
maxHeight?: number;
maxBytes?: number;
jpegQuality?: number;
}
export interface ResizedImage {
data: string;
mimeType: string;
originalWidth: number;
originalHeight: number;
width: number;
height: number;
wasResized: boolean;
}
export interface ProcessImageOptions {
autoResizeImages?: boolean;
resizeOptions?: ModelImageResizeOptions;
}
export type ProcessImageResult = {
ok: true;
data: string;
mimeType: string;
hints: string[];
} | {
ok: false;
message: string;
};
export type SupportedImageMimeType = "image/jpeg" | "image/png" | "image/gif" | "image/webp" | "image/bmp";
+55 -2
View File
@@ -461,6 +461,54 @@ async function renderSvg(svg: string, opts: RenderSvgOptions = {}): Promise<Buff
// ─── PDF page rasterization (pdfjs paints, Skia hosts) ───────────────── // ─── PDF page rasterization (pdfjs paints, Skia hosts) ─────────────────
/** Decimal megapixels: at four bytes per pixel this is about 61 MiB per
* page canvas, before pdfjs/Skia's other working memory. */
export const MAX_PDF_CANVAS_PIXELS = 16_000_000;
export const MAX_PDF_CANVAS_SIDE = 8192;
/** Plan the allocation from the scale-1 viewport (including PDF rotation).
* Keep ordinary DPI/ceil arithmetic unchanged; oversized pages lose scale,
* never gain it. Exported as a pure seam so extreme geometry needs no canvas. */
export function pdfCanvasGeometry(baseWidth: number, baseHeight: number, dpi: number): {
scale: number;
width: number;
height: number;
} {
if (![baseWidth, baseHeight].every((n) => Number.isFinite(n) && n > 0)) {
throw new Error("PDF page dimensions must be finite and positive");
}
if (!Number.isFinite(dpi) || dpi <= 0 || dpi / 72 === 0) {
throw new Error("PDF render DPI must be finite and positive");
}
// Limit sides first, so the area calculation cannot overflow even when
// the unscaled page dimensions or requested DPI are enormous.
let scale = Math.min(dpi / 72, MAX_PDF_CANVAS_SIDE / baseWidth, MAX_PDF_CANVAS_SIDE / baseHeight);
const area = (baseWidth * scale) * (baseHeight * scale);
if (area > MAX_PDF_CANVAS_PIXELS) scale *= Math.sqrt(MAX_PDF_CANVAS_PIXELS / area);
const dimensions = (s: number) => ({
width: Math.max(1, Math.ceil(baseWidth * s)),
height: Math.max(1, Math.ceil(baseHeight * s)),
});
const fits = ({ width, height }: { width: number; height: number }) =>
width <= MAX_PDF_CANVAS_SIDE && height <= MAX_PDF_CANVAS_SIDE && width * height <= MAX_PDF_CANVAS_PIXELS;
// Ceil can put an otherwise bounded floating-point viewport over budget.
// Find a safe scale rather than clipping the viewport or allocating first.
if (!fits(dimensions(scale))) {
let low = 0;
let high = scale;
for (let i = 0; i < 64; i++) {
const mid = low + (high - low) / 2;
if (fits(dimensions(mid))) low = mid;
else high = mid;
}
scale = low;
}
if (scale <= 0 || baseWidth * scale === 0 || baseHeight * scale === 0) {
throw new Error("PDF page dimensions cannot be rendered at a positive scale");
}
return { scale, ...dimensions(scale) };
}
/** Render PDF pages to PNGs — the sources-v0 pdf-reader's eyes. /** Render PDF pages to PNGs — the sources-v0 pdf-reader's eyes.
* *
* History: pdfjs ALWAYS painted the pages (scale = dpi/72, the skill's * History: pdfjs ALWAYS painted the pages (scale = dpi/72, the skill's
@@ -475,6 +523,9 @@ async function renderPdfPages(
opts: RenderPdfOptions = {}, opts: RenderPdfOptions = {},
): Promise<RenderedPdfPage[]> { ): Promise<RenderedPdfPage[]> {
const dpi = opts.dpi ?? 150; const dpi = opts.dpi ?? 150;
if (!Number.isFinite(dpi) || dpi <= 0 || dpi / 72 === 0) {
throw new Error("PDF render DPI must be finite and positive");
}
// NATIVE CANVAS (Wave E, owner 2026-10-10): pdfjs paints into // NATIVE CANVAS (Wave E, owner 2026-10-10): pdfjs paints into
// @napi-rs/canvas in this process — no browser involved. pdfjs-dist v6's // @napi-rs/canvas in this process — no browser involved. pdfjs-dist v6's
// own Node integration IS @napi-rs/canvas (its optionalDependency: the // own Node integration IS @napi-rs/canvas (its optionalDependency: the
@@ -495,8 +546,10 @@ async function renderPdfPages(
for (const pageNo of pages) { for (const pageNo of pages) {
if (pageNo < 1 || pageNo > doc.numPages) continue; if (pageNo < 1 || pageNo > doc.numPages) continue;
const pg = await doc.getPage(pageNo); const pg = await doc.getPage(pageNo);
const vp = pg.getViewport({ scale: dpi / 72 }); const base = pg.getViewport({ scale: 1 });
const canvas = createCanvas(Math.ceil(vp.width), Math.ceil(vp.height)); const geometry = pdfCanvasGeometry(base.width, base.height, dpi);
const vp = pg.getViewport({ scale: geometry.scale });
const canvas = createCanvas(geometry.width, geometry.height);
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
// pdfjs's render types speak the DOM context; @napi-rs/canvas's // pdfjs's render types speak the DOM context; @napi-rs/canvas's
// context is the same 2D API implemented over Skia. // context is the same 2D API implemented over Skia.
+19 -10
View File
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai"; import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai";
import { normalizeContext } from "@earendil-works/pi-ai"; import { normalizeContext } from "@earendil-works/pi-ai";
import { processImage, type ProcessImageResult } from "../image-processing/index.ts";
import { Type } from "typebox"; import { Type } from "typebox";
import { resolveSourcePath } from "../tools/fs-shared.ts"; import { resolveSourcePath } from "../tools/fs-shared.ts";
import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts"; import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts";
@@ -246,7 +247,7 @@ export async function runPdfReaderAgent(
const toolResults: ToolResultMessage[] = []; const toolResults: ToolResultMessage[] = [];
/** Rendered pages this batch, appended as images AFTER the tool /** Rendered pages this batch, appended as images AFTER the tool
* results — the visual-maker's image-in-the-loop idiom. */ * results — the visual-maker's image-in-the-loop idiom. */
let renderedImages: { page: number; png: Buffer }[] | null = null; let renderedImages: { page: number; image: ProcessImageResult }[] | null = null;
for (const call of toolCalls) { for (const call of toolCalls) {
const args = (call.arguments ?? {}) as Record<string, unknown>; const args = (call.arguments ?? {}) as Record<string, unknown>;
@@ -287,19 +288,26 @@ export async function runPdfReaderAgent(
const wanted = specNumbers(spec, RENDER_BATCH_CAP); const wanted = specNumbers(spec, RENDER_BATCH_CAP);
opts.onActivity?.(`pdf_render: ${spec} @${dpi}dpi`); opts.onActivity?.(`pdf_render: ${spec} @${dpi}dpi`);
const pages = await headless.renderPdfPages(pdfPath, wanted, { dpi }); const pages = await headless.renderPdfPages(pdfPath, wanted, { dpi });
const processed: { page: number; image: ProcessImageResult }[] = [];
for (const p of pages) { for (const p of pages) {
consulted.add(p.page); const image = await processImage(p.png, "image/png", { resizeOptions: engine.model.inputLimits?.images?.resize });
read.add(p.page); opts.abortSignal?.throwIfAborted();
processed.push({ page: p.page, image });
// Only images actually delivered to the model count as read.
if (image.ok) {
consulted.add(p.page);
read.add(p.page);
}
} }
if (pages.length === 0) { if (pages.length === 0) {
toolResults.push(toolText(call.id, call.name, "ERROR: no pages rendered (spec matched nothing?)")); toolResults.push(toolText(call.id, call.name, "ERROR: no pages rendered (spec matched nothing?)"));
} else { } else {
renderedImages = pages; renderedImages = [...(renderedImages ?? []), ...processed];
toolResults.push( toolResults.push(
toolText( toolText(
call.id, call.id,
call.name, call.name,
`Rendered ${pages.length} page(s) at ${dpi} DPI — the images follow below. Read them carefully (equations, diagrams, layout).`, `Rendered ${pages.length} page(s) at ${dpi} DPI — processed images or explicit omission notes follow below. Read the images carefully (equations, diagrams, layout).`,
), ),
); );
} }
@@ -323,11 +331,12 @@ export async function runPdfReaderAgent(
type: "text", type: "text",
text: `The rendered page(s), in order (${renderedImages.map((p) => `p${p.page}`).join(", ")}):`, text: `The rendered page(s), in order (${renderedImages.map((p) => `p${p.page}`).join(", ")}):`,
}, },
...renderedImages.map((p) => ({ ...renderedImages.flatMap(({ page, image }) => image.ok
type: "image" as const, ? [
data: p.png.toString("base64"), { type: "text" as const, text: [`Page ${page}`, ...image.hints].join("\n") },
mimeType: "image/png", { type: "image" as const, data: image.data, mimeType: image.mimeType },
})), ]
: [{ type: "text" as const, text: `Page ${page}: ${image.message}` }]),
], ],
timestamp: Date.now(), timestamp: Date.now(),
}, },
+14 -7
View File
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { AssistantMessage, Message, Tool, ToolResultMessage, Usage } from "@earendil-works/pi-ai"; import type { AssistantMessage, Message, Tool, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
import { normalizeContext } from "@earendil-works/pi-ai"; import { normalizeContext } from "@earendil-works/pi-ai";
import { processImage, type ProcessImageResult } from "../image-processing/index.ts";
import { Type } from "typebox"; import { Type } from "typebox";
import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts"; import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts";
import { providerEventObserver, type ProviderEventObserver } from "../provider-events.ts"; import { providerEventObserver, type ProviderEventObserver } from "../provider-events.ts";
@@ -193,6 +194,9 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
return { svg: "", notes: `FAILED: rendering the final SVG failed — ${msg}` }; return { svg: "", notes: `FAILED: rendering the final SVG failed — ${msg}` };
} }
const image = await processImage(png, "image/png", { resizeOptions: engine.model.inputLimits?.images?.resize });
opts.abortSignal?.throwIfAborted();
if (!image.ok) return { svg: "", notes: `FAILED: inspecting the final SVG failed — ${image.message}` };
opts.onActivity?.("render_svg: inspecting final render"); opts.onActivity?.("render_svg: inspecting final render");
renderedSvg = currentSvg; renderedSvg = currentSvg;
messages = [ messages = [
@@ -200,8 +204,8 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
{ {
role: "user", role: "user",
content: [ content: [
{ type: "text", text: UNLOOKED_FINISH_MESSAGE }, { type: "text", text: [UNLOOKED_FINISH_MESSAGE, ...image.hints].join("\n") },
{ type: "image", data: png.toString("base64"), mimeType: "image/png" }, { type: "image", data: image.data, mimeType: image.mimeType },
], ],
timestamp: Date.now(), timestamp: Date.now(),
}, },
@@ -212,7 +216,7 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
} }
const toolResults: ToolResultMessage[] = []; const toolResults: ToolResultMessage[] = [];
let renderPng: Buffer | null = null; // appended as the image message AFTER the tool results let renderImage: Extract<ProcessImageResult, { ok: true }> | null = null; // appended AFTER the tool results
for (const call of toolCalls) { for (const call of toolCalls) {
if (call.name === "write_svg") { if (call.name === "write_svg") {
const svg = (call.arguments as { svg?: unknown }).svg; const svg = (call.arguments as { svg?: unknown }).svg;
@@ -260,7 +264,10 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
} }
try { try {
const png = await headless.renderSvg(currentSvg); const png = await headless.renderSvg(currentSvg);
renderPng = png; const image = await processImage(png, "image/png", { resizeOptions: engine.model.inputLimits?.images?.resize });
opts.abortSignal?.throwIfAborted();
if (!image.ok) throw new Error(image.message);
renderImage = image;
renderedSvg = currentSvg; renderedSvg = currentSvg;
opts.onActivity?.("render_svg: inspecting render"); opts.onActivity?.("render_svg: inspecting render");
toolResults.push(toolText(call.id, call.name, "Rendered — the PNG follows in the next message. Look at it.")); toolResults.push(toolText(call.id, call.name, "Rendered — the PNG follows in the next message. Look at it."));
@@ -275,14 +282,14 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
} }
// Message order: assistant tool-calls → tool results → render image. // Message order: assistant tool-calls → tool results → render image.
messages = [...messages, ...toolResults]; messages = [...messages, ...toolResults];
if (renderPng) { if (renderImage) {
messages = [ messages = [
...messages, ...messages,
{ {
role: "user", role: "user",
content: [ content: [
{ type: "text", text: LOOK_MESSAGE }, { type: "text", text: [LOOK_MESSAGE, ...renderImage.hints].join("\n") },
{ type: "image", data: renderPng.toString("base64"), mimeType: "image/png" }, { type: "image", data: renderImage.data, mimeType: renderImage.mimeType },
], ],
timestamp: Date.now(), timestamp: Date.now(),
}, },
+4 -1
View File
@@ -17,6 +17,7 @@ import {
import { practiceTools, type PracticeToolContext } from "./tools/practice.ts"; import { practiceTools, type PracticeToolContext } from "./tools/practice.ts";
import { healToolRecord, withArgumentHeal } from "./tools/heal.ts"; import { healToolRecord, withArgumentHeal } from "./tools/heal.ts";
import { sendToUserTool } from "./tools/send-to-user.ts"; import { sendToUserTool } from "./tools/send-to-user.ts";
import { createReadTool, readTool } from "./tools/read.ts";
import { quizInputObject, quizInputProblems } from "../core/question-core.ts"; import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts"; import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
import { toLlmMessages, nativeInputCause, PRACTICE_PROBLEM_TOOL } from "../core/native-messages.ts"; import { toLlmMessages, nativeInputCause, PRACTICE_PROBLEM_TOOL } from "../core/native-messages.ts";
@@ -201,7 +202,9 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
// Match Claude across providers (including OpenRouter), not the API // Match Claude across providers (including OpenRouter), not the API
// transport. Explicit test registries remain caller-owned. // transport. Explicit test registries remain caller-owned.
tools: [ tools: [
...tools, ...tools.map((tool) => tool.name === "read" && tool.execute === readTool.execute
? { ...tool, execute: createReadTool(engine.model).execute as AgentTool<any>["execute"] }
: tool),
...(opts.tools === undefined && engine.model.id.toLowerCase().includes("claude") ...(opts.tools === undefined && engine.model.id.toLowerCase().includes("claude")
? [withArgumentHeal(sendToUserTool)] ? [withArgumentHeal(sendToUserTool)]
: []), : []),
+38 -35
View File
@@ -1,9 +1,10 @@
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as path from "node:path";
import { Type } from "typebox"; import { Type } from "typebox";
import type { Api, Model } from "@earendil-works/pi-ai";
import type { AgentTool } from "@earendil-works/pi-agent-core"; import type { AgentTool } from "@earendil-works/pi-agent-core";
import { serverExecute, type ImageFileResult } from "../tool-execute.ts"; import { serverExecute, type ImageFileResult } from "../tool-execute.ts";
import { resolveSourcePath } from "./fs-shared.ts"; import { resolveSourcePath } from "./fs-shared.ts";
import { detectSupportedImageMimeTypeFromFile, processImage } from "../image-processing/index.ts";
/** /**
* The read tool — sources v0 (2026-09): a Node port of pi's read semantics * The read tool — sources v0 (2026-09): a Node port of pi's read semantics
@@ -31,17 +32,6 @@ import { resolveSourcePath } from "./fs-shared.ts";
const MAX_LINES = 2000; const MAX_LINES = 2000;
const MAX_BYTES = 50 * 1024; const MAX_BYTES = 50 * 1024;
/** Extension map for v0 (pi sniffs magic bytes; extensions suffice here —
* learner files are pasted paths, not adversarial uploads). */
const IMAGE_MIME: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
};
const readInput = Type.Object({ const readInput = Type.Object({
path: Type.String({ minLength: 1, description: "Path to the file to read (relative or absolute)" }), path: Type.String({ minLength: 1, description: "Path to the file to read (relative or absolute)" }),
offset: Type.Optional(Type.Integer({ minimum: 1, description: "Line number to start reading from (1-indexed)" })), offset: Type.Optional(Type.Integer({ minimum: 1, description: "Line number to start reading from (1-indexed)" })),
@@ -95,26 +85,39 @@ function readTextSlice(text: string, offset: number | undefined, limit: number |
* fragment (prompts/shared/). Relative paths resolve against the * fragment (prompts/shared/). Relative paths resolve against the
* learner's home (fs-shared.ts), the harness's anchor. * learner's home (fs-shared.ts), the harness's anchor.
*/ */
export const readTool: AgentTool<typeof readInput> = { export type ReadModel = Pick<Model<Api>, "input" | "inputLimits">;
name: "read",
label: "read", /** One implementation for every learner-file reader. Bind the active model
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50.0KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`, * per agent, rather than sharing mutable model state between conversations.
parameters: readInput, * Home-relative paths, text slicing and the app's abort guard stay unchanged;
execute: serverExecute("read", readInput, async ({ path: rawPath, offset, limit }): Promise<string | ImageFileResult> => { * image detection/processing and its notes follow native Pi read behavior. */
const absolute = resolveSourcePath(rawPath); export function createReadTool(model?: ReadModel): AgentTool<typeof readInput> {
const ext = path.extname(absolute).toLowerCase(); return {
const mime = IMAGE_MIME[ext]; name: "read",
const buffer = await fs.readFile(absolute); // ENOENT/EACCES → error result the model sees label: "read",
if (mime) { description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50.0KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
// NOTE v0: no resize — raw bytes as base64. Learner files are study parameters: readInput,
// material (screenshots, figures), not adversarial payloads; a resize execute: serverExecute("read", readInput, async ({ path: rawPath, offset, limit }, { signal }): Promise<string | ImageFileResult> => {
// step (sharp) can land later if large images prove wasteful. const absolute = resolveSourcePath(rawPath);
return { const mime = await detectSupportedImageMimeTypeFromFile(absolute);
text: `Read image file [${mime}] — ${path.basename(absolute)} (${formatSize(buffer.byteLength)}), attached as an image.`, const buffer = await fs.readFile(absolute, { signal });
images: [{ data: buffer.toString("base64"), mimeType: mime }], if (mime) {
}; const processed = await processImage(buffer, mime, { resizeOptions: model?.inputLimits?.images?.resize });
} signal?.throwIfAborted();
const text = buffer.toString("utf-8"); const note = processed.ok
return readTextSlice(text, offset, limit); ? [`Read image file [${processed.mimeType}]`, ...processed.hints]
}), : [`Read image file [${mime}]`, processed.message];
}; if (model && !model.input.includes("image")) {
note.push("[Current model does not support images. The image will be omitted from this request.]");
}
return {
text: note.join("\n"),
...(processed.ok ? { images: [{ data: processed.data, mimeType: processed.mimeType }] } : {}),
};
}
return readTextSlice(buffer.toString("utf-8"), offset, limit);
}),
};
}
export const readTool = createReadTool();
+86
View File
@@ -0,0 +1,86 @@
/** A lossless projection of plain learner text, not a Markdown parser. */
export type FilePathPart =
| { kind: "text"; text: string; start: number; end: number }
| { kind: "path"; text: string; path: string; start: number; end: number };
// No filesystem access or normalization: even punctuation and backslashes can
// be legitimate POSIX filename characters. Only rooted syntax is required.
function isFilePath(path: string): boolean {
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(path)) return false;
return path.startsWith("/")
|| path.startsWith("~/")
|| /^[a-z]:[\\/]/i.test(path)
|| /^\\\\[^\\/]+[\\/][^\\/]+(?:[\\/]|$)/u.test(path);
}
/**
* Wrap a rooted path for storage/transmission as ordinary, lossless text.
* CommonMark's one-space padding prevents edge backticks joining delimiters.
*/
export function quoteFilePath(path: string): string {
if (!isFilePath(path)) throw new TypeError("Expected a single-line rooted file path");
let width = 1;
for (const run of path.matchAll(/`+/g)) width = Math.max(width, run[0].length + 1);
const delimiter = "`".repeat(width);
const padding = path.startsWith("`") || path.endsWith("`") ? " " : "";
return `${delimiter}${padding}${path}${padding}${delimiter}`;
}
/**
* Recognize every complete backtick-wrapped rooted path, even beside words.
* Delimiters are whole runs of exactly equal length; a run inside another
* code span is never reconsidered as a partial path token. Offsets are JS
* string (UTF-16) offsets and `text` always slices the exact original input.
*/
export function parseFilePaths(text: string): FilePathPart[] {
const runs = Array.from(text.matchAll(/`+/g), (match) => ({
start: match.index,
end: match.index + match[0].length,
width: match[0].length,
}));
// The next same-width run lets unmatched openers remain plain text without
// repeatedly scanning the rest of a long draft.
const next = new Map<number, number>();
const closing = new Map<number, number>();
for (let i = runs.length - 1; i >= 0; i--) {
const width = runs[i]!.width;
const match = next.get(width);
if (match !== undefined) closing.set(i, match);
next.set(width, i);
}
const parts: FilePathPart[] = [];
let plainStart = 0;
for (let i = 0; i < runs.length; i++) {
const closeIndex = closing.get(i);
if (closeIndex === undefined) continue;
const open = runs[i]!;
const close = runs[closeIndex]!;
let path = text.slice(open.end, close.start);
// CommonMark strips exactly one space at each edge, not all whitespace.
if (path.startsWith(" ") && path.endsWith(" ") && /[^ ]/.test(path)) {
path = path.slice(1, -1);
}
if (isFilePath(path)) {
if (plainStart < open.start) {
parts.push({ kind: "text", text: text.slice(plainStart, open.start), start: plainStart, end: open.start });
}
parts.push({ kind: "path", text: text.slice(open.start, close.end), path, start: open.start, end: close.end });
plainStart = close.end;
}
i = closeIndex;
}
if (plainStart < text.length) {
parts.push({ kind: "text", text: text.slice(plainStart), start: plainStart, end: text.length });
}
return parts;
}
/** Basename for display only; the original full path remains untouched. */
export function filePathLabel(path: string): string {
const windows = /^[a-z]:[\\/]/i.test(path) || path.startsWith("\\\\");
const separator = windows ? /[\\/]/ : /\//;
const trimmed = windows ? path.replace(/[\\/]+$/, "") : path.replace(/\/+$/, "");
if (!trimmed || (windows && /^[a-z]:$/i.test(trimmed)) || trimmed === "~") return path;
return trimmed.split(separator).at(-1) || path;
}
+2
View File
@@ -42,6 +42,8 @@ export * from "./math-delims.ts";
export * from "./native-messages.ts"; export * from "./native-messages.ts";
export * from "./compaction.ts"; export * from "./compaction.ts";
export * from "./input-events.ts"; export * from "./input-events.ts";
export { parseFilePaths, quoteFilePath, filePathLabel } from "./file-paths.ts";
export type { FilePathPart } from "./file-paths.ts";
// Wave D (owner-approved 2026-09-26): the client view model — the grouping // Wave D (owner-approved 2026-09-26): the client view model — the grouping
// canon (one continuous assistant document, step separators) ported from // canon (one continuous assistant document, step separators) ported from
// the deleted server projection to the client side. Pure, browser-safe. // the deleted server projection to the client side. Pure, browser-safe.
+351
View File
@@ -0,0 +1,351 @@
/** Offline integration contracts at the provider boundary: real shared image
* processing, faux model streams, synthetic rasters, and fake headless I/O.
* No real browser, provider, learner data, or ambient credentials are used. */
import assert from "node:assert/strict";
import * as fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { after, test } from "node:test";
import { createCanvas } from "@napi-rs/canvas";
import * as photon from "@silvia-odwyer/photon-node";
import {
createFauxCore,
fauxAssistantMessage,
fauxText,
fauxToolCall,
type FauxResponseStep,
type ImageContent,
type TranscriptContext,
} from "@earendil-works/pi-ai";
import type { PiEngine } from "../src/agent/models.ts";
import type { ModelImageResizeOptions } from "../src/agent/image-processing/index.ts";
import type { HeadlessApi } from "../src/agent/subagents/headless.ts";
// Install storage/auth seams BEFORE importing the agent composition root.
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-agent-image-paths-"));
const originalEnv = process.env;
const originalFetch = globalThis.fetch;
fs.mkdirSync(path.join(scratch, "home"));
fs.writeFileSync(path.join(scratch, "auth.json"), "{}");
process.env = {
...(originalEnv.NODE_TEST_CONTEXT ? { NODE_TEST_CONTEXT: originalEnv.NODE_TEST_CONTEXT } : {}),
...(originalEnv.NODE_OPTIONS ? { NODE_OPTIONS: originalEnv.NODE_OPTIONS } : {}),
NODE_ENV: "test",
HOME: path.join(scratch, "home"),
LH_DATA_DIR: path.join(scratch, "data"),
LH_AUTH_FILE: path.join(scratch, "auth.json"),
};
globalThis.fetch = async () => { throw new Error("CLOSED: unexpected network request"); };
after(() => {
process.env = originalEnv;
globalThis.fetch = originalFetch;
fs.rmSync(scratch, { recursive: true, force: true });
});
const { readTool } = await import("../src/agent/tools/read.ts");
const { createTeacherAgent, createProberAgent, createPracticeAgent } = await import("../src/agent/teacher-agent.ts");
const { runPdfReaderAgent } = await import("../src/agent/subagents/pdf-reader.ts");
const { runVisualMaker } = await import("../src/agent/subagents/visual-maker.ts");
const PDF = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
const SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="4800" height="120" viewBox="0 0 4800 120"><rect width="4800" height="120" fill="white"/></svg>';
const CUSTOM_RESIZE = { maxWidth: 320, maxHeight: 200 };
function png(width: number, height: number): Buffer {
const canvas = createCanvas(width, height);
const context = canvas.getContext("2d");
context.fillStyle = "#336699";
context.fillRect(0, 0, width, height);
return canvas.toBuffer("image/png");
}
function dimensions(image: ImageContent): { width: number; height: number } {
const decoded = photon.PhotonImage.new_from_byteslice(Buffer.from(image.data, "base64"));
try {
return { width: decoded.get_width(), height: decoded.get_height() };
} finally { decoded.free(); }
}
function images(context: TranscriptContext): ImageContent[] {
return context.messages.flatMap((message) => Array.isArray(message.content)
? message.content.filter((part): part is ImageContent => part.type === "image")
: []);
}
function text(context: TranscriptContext): string {
return context.messages.map((message) => typeof message.content === "string"
? message.content
: message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n")).join("\n");
}
function scripted(steps: FauxResponseStep[], resize?: ModelImageResizeOptions, vision = true) {
const faux = createFauxCore({ tokenSize: { min: 64, max: 64 } });
faux.setResponses(steps);
const base = faux.getModel();
const model: PiEngine["model"] = {
...base,
input: vision ? ["text", "image"] : ["text"],
...(resize ? { inputLimits: { ...base.inputLimits, images: { ...base.inputLimits?.images, resize } } } : {}),
};
const requests: TranscriptContext[] = [];
const engine: PiEngine = {
model,
streamFn: (model, context, options) => {
// Snapshot the actual provider-facing context, not mutable agent state.
requests.push(structuredClone(context));
return faux.streamSimple(model, context, options);
},
};
return { engine, requests };
}
function readSteps(file: string): FauxResponseStep[] {
return [
fauxAssistantMessage([fauxToolCall("read", { path: file }, { id: "read-1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Inspected the supplied source.")]),
];
}
function fakeHeadless(rendered: Buffer | ((page: number) => Buffer)) {
const svgCalls: string[] = [];
const pdfCalls: number[][] = [];
const headless: HeadlessApi = {
available: () => true,
unavailableReason: () => null,
runDemo: async () => { throw new Error("unexpected browser execution"); },
renderSvg: async (svg) => {
svgCalls.push(svg);
return typeof rendered === "function" ? rendered(1) : rendered;
},
renderPdfPages: async (_file, pages) => {
pdfCalls.push([...pages]);
return pages.map((page) => ({ page, png: typeof rendered === "function" ? rendered(page) : rendered }));
},
};
return { headless, svgCalls, pdfCalls };
}
function assertProcessedImage(context: TranscriptContext, expected: { width: number; height: number }, raw: Buffer): void {
const parts = images(context);
assert.equal(parts.length, 1, "exactly one successful image reaches the faux provider");
assert.deepEqual(dimensions(parts[0]!), expected);
assert.ok(Buffer.byteLength(parts[0]!.data, "utf8") < 4.5 * 1024 * 1024);
assert.notEqual(parts[0]!.data, raw.toString("base64"), "the oversized raw raster is never sent");
assert.match(text(context), /\[Image: original \d+x\d+, displayed at \d+x\d+\. Multiply coordinates by/);
}
test("shared teacher read detects valid image content under a non-image extension", async () => {
const bytes = png(24, 16);
const file = path.join(scratch, "lecture-notes.txt");
fs.writeFileSync(file, bytes);
const { engine, requests } = scripted(readSteps(file));
// Pass the actual shared read tool, exercising the teacher's native clone
// of read.execute rather than a pre-bound test-only implementation.
const agent = createTeacherAgent(undefined, { engine, tools: { read: readTool } });
await agent.prompt("Inspect the supplied local file.");
assert.equal(requests.length, 2);
const visible = requests[1]!;
assert.equal(images(visible).length, 1);
assert.equal(images(visible)[0]!.mimeType, "image/png");
assert.equal(images(visible)[0]!.data, bytes.toString("base64"));
assert.deepEqual(dimensions(images(visible)[0]!), { width: 24, height: 16 });
assert.match(text(visible), /Read image file \[image\/png\]/);
});
test("shared read omits corrupt recognized images without exposing a raw image fallback", async () => {
// Valid signature/IHDR, missing pixel data: sniff succeeds, decode fails.
const bytes = png(24, 16).subarray(0, 33);
const file = path.join(scratch, "corrupt.png");
fs.writeFileSync(file, bytes);
const { engine, requests } = scripted(readSteps(file));
await createTeacherAgent(undefined, { engine, tools: { read: readTool } }).prompt("Inspect the file.");
assert.equal(requests.length, 2);
assert.deepEqual(images(requests[1]!), []);
assert.match(text(requests[1]!), /Image omitted: could not be resized below the inline image size limit/);
assert.ok(!JSON.stringify(requests[1]).includes(bytes.toString("base64")));
});
test("shared read supplies the native nonvision-model note", async () => {
const file = path.join(scratch, "nonvision.png");
fs.writeFileSync(file, png(24, 16));
const { engine, requests } = scripted(readSteps(file), undefined, false);
await createTeacherAgent(undefined, { engine, tools: { read: readTool } }).prompt("Inspect the file.");
assert.equal(requests.length, 2);
assert.match(text(requests[1]!), /\[Current model does not support images\. The image will be omitted from this request\.\]/);
});
test("teacher read binds independent active-model resize limits without mutating the shared tool", async () => {
const bytes = png(640, 320);
const file = path.join(scratch, "model-bound.png");
fs.writeFileSync(file, bytes);
const originalExecute = readTool.execute;
const a = scripted(readSteps(file), { maxWidth: 96, maxHeight: 64 });
const b = scripted(readSteps(file), { maxWidth: 80, maxHeight: 30 });
const agentA = createTeacherAgent(undefined, { engine: a.engine, tools: { read: readTool } });
const agentB = createTeacherAgent(undefined, { engine: b.engine, tools: { read: readTool } });
await agentB.prompt("Inspect with model B.");
await agentA.prompt("Inspect with model A.");
assertProcessedImage(a.requests[1]!, { width: 96, height: 48 }, bytes);
assertProcessedImage(b.requests[1]!, { width: 60, height: 30 }, bytes);
assert.equal(readTool.execute, originalExecute);
});
for (const [role, resize, expected] of [
["prober", { maxWidth: 96, maxHeight: 64 }, { width: 96, height: 48 }],
["practice", { maxWidth: 80, maxHeight: 30 }, { width: 60, height: 30 }],
] as const) {
test(`${role}'s actual registry retains argument healing and text reads while binding image limits`, async () => {
const bytes = png(640, 320);
const imageFile = path.join(scratch, `${role}-actual-registry.png`);
const textFile = path.join(scratch, `${role}-actual-registry.txt`);
fs.writeFileSync(imageFile, bytes);
fs.writeFileSync(textFile, "first\nsecond\nthird\nfourth\nfifth");
const { engine, requests } = scripted([
fauxAssistantMessage([fauxToolCall("read", { path: imageFile }, { id: "registry-image" })], { stopReason: "toolUse" }),
// Exact existing heal contract: schema-integer fields encoded as
// JSON number strings heal before validation. The path stays intact.
fauxAssistantMessage([fauxToolCall("read", { path: textFile, offset: "2", limit: "2" }, { id: "registry-text" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Inspected the image and selected text lines.")]),
], resize);
const originalExecute = readTool.execute;
const forbidden = () => { throw new Error("unexpected job dispatch or handoff"); };
const chatStore = { getChat: () => null };
// Only context ports are mocked; no app/store imports or explicit
// tool overrides. Job/handoff operations must never execute here.
const agent = role === "prober"
? createProberAgent({
chatId: "offline-read-prober", chatStore,
jobStore: new Proxy({}, { get: forbidden }),
spawn: forbidden, deliver: forbidden,
} as never, { engine })
: createPracticeAgent({ chatId: "offline-read-practice", chatStore }, { engine });
const names = agent.state.tools.map((tool) => tool.name);
assert.ok(names.includes(role === "prober" ? "probe_done" : "problem"), "real role registry was assembled");
const boundRead = agent.state.tools.find((tool) => tool.name === "read");
assert.ok(boundRead?.prepareArguments, "model binding preserves the registry's prepareArguments wrapper");
assert.notEqual(boundRead.execute, originalExecute, "shared read execute is rebound to the active model");
assert.deepEqual(boundRead.prepareArguments({ path: textFile, offset: "2", limit: "2" }), { path: textFile, offset: 2, limit: 2 });
await agent.prompt("Inspect the local image, then read the selected text lines.");
assert.equal(requests.length, 3);
assertProcessedImage(requests[1]!, expected, bytes);
const selected = requests[2]!.messages.find((message) => message.role === "toolResult" && message.toolCallId === "registry-text");
assert.ok(selected && selected.role === "toolResult");
assert.equal(selected.isError, false);
assert.deepEqual(selected.content, [{ type: "text", text: "second\nthird\n\n[2 more lines in file. Use offset=4 to continue.]" }]);
assert.equal(readTool.execute, originalExecute, "role construction did not mutate the shared tool");
});
}
test("PDF reader preserves both processed image outputs from multiple render calls in one batch", async () => {
const wide = png(4800, 120);
const tall = png(120, 4800);
const { headless, pdfCalls } = fakeHeadless((page) => page === 1 ? wide : tall);
const { engine, requests } = scripted([
fauxAssistantMessage([
fauxToolCall("pdf_render", { pages: "1", dpi: 150 }, { id: "batch-render-1" }),
fauxToolCall("pdf_render", { pages: "2", dpi: 150 }, { id: "batch-render-2" }),
], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Both rendered pages are visible (p. 1, p. 2). Pages consulted: 1, 2")]),
]);
const result = await runPdfReaderAgent({ path: PDF, question: "Compare the two figures." }, { engine, headless });
assert.equal(requests.length, 2);
assert.deepEqual(pdfCalls, [[1], [2]]);
const visible = requests[1]!;
const parts = images(visible);
assert.equal(parts.length, 2, "the second render call must not overwrite the first call's images");
assert.deepEqual(parts.map(dimensions), [{ width: 2000, height: 50 }, { width: 50, height: 2000 }]);
for (let i = 0; i < parts.length; i++) {
assert.ok(parts[i]!.data.length < 4.5 * 1024 * 1024);
assert.notEqual(parts[i]!.data, [wide, tall][i]!.toString("base64"));
}
assert.deepEqual(visible.messages.slice(-3).map((message) => message.role), ["toolResult", "toolResult", "user"]);
const pageMessage = visible.messages.at(-1)!;
assert.ok(Array.isArray(pageMessage.content));
assert.deepEqual(pageMessage.content.filter((part) => part.type === "text").map((part) => part.text.split("\n")[0]), [
"The rendered page(s), in order (p1, p2):", "Page 1", "Page 2",
]);
assert.deepEqual(result.pagesRead, [1, 2]);
assert.deepEqual(result.pagesConsulted, [1, 2]);
});
for (const [label, resize, expected] of [
["native defaults", undefined, { width: 2000, height: 50 }],
["active-model overrides", CUSTOM_RESIZE, { width: 320, height: 8 }],
] as const) {
test(`PDF reader sends processed renders using ${label}; failed page is not counted`, async () => {
const bytes = png(4800, 120);
const { headless, pdfCalls } = fakeHeadless((page) => page === 1 ? bytes : Buffer.from("invalid rendered page"));
const { engine, requests } = scripted([
fauxAssistantMessage([fauxToolCall("pdf_render", { pages: "1-2", dpi: 150 }, { id: "render-1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("The visible figure is on page 1 (p. 1). Pages consulted: 1")]),
], resize);
const result = await runPdfReaderAgent({ path: PDF, question: "Inspect the page figure." }, { engine, headless });
assert.equal(requests.length, 2);
assert.deepEqual(pdfCalls, [[1, 2]]);
assertProcessedImage(requests[1]!, expected, bytes);
assert.match(text(requests[1]!), /Page 2: \[Image omitted:/);
assert.deepEqual(result.pagesRead, [1]);
assert.deepEqual(result.pagesConsulted, [1]);
assert.match(result.brief, /visible figure/);
const visible = requests[1]!.messages;
assert.equal(visible.at(-2)!.role, "toolResult");
assert.equal(visible.at(-1)!.role, "user", "processed page images ride the user message after tool results");
});
for (const forced of [false, true]) {
test(`visual maker ${forced ? "forced-final" : "explicit render_svg"} path processes images using ${label}`, async () => {
const bytes = png(4800, 120);
const { headless, svgCalls } = fakeHeadless(bytes);
const steps: FauxResponseStep[] = forced ? [
fauxAssistantMessage([fauxToolCall("write_svg", { svg: SVG }, { id: "write-1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Premature notes without inspection.")]),
fauxAssistantMessage([fauxText("Confirmed after inspecting the final render.")]),
] : [
fauxAssistantMessage([
fauxToolCall("write_svg", { svg: SVG }, { id: "write-1" }),
fauxToolCall("render_svg", {}, { id: "render-1" }),
], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Confirmed after inspecting the render.")]),
];
const { engine, requests } = scripted(steps, resize);
const result = await runVisualMaker({ brief: "One precise synthetic figure." }, { engine, headless });
assert.equal(requests.length, forced ? 3 : 2);
assert.deepEqual(svgCalls, [SVG]);
assertProcessedImage(requests.at(-1)!, expected, bytes);
assert.equal(result.svg, SVG);
assert.match(result.notes!, /^Confirmed after inspecting/);
assert.equal(requests.at(-1)!.messages.at(-1)!.role, "user");
if (forced) {
assert.deepEqual(images(requests[1]!), [], "premature finish did not see an image yet");
assert.match(text(requests[2]!), /You finished without looking at a render of your final SVG/);
} else {
assert.match(text(requests[1]!), /Here is the current render of your SVG/);
assert.equal(requests[1]!.messages.at(-2)!.role, "toolResult");
}
});
}
}
for (const forced of [false, true]) {
test(`visual maker ${forced ? "forced-final" : "explicit render_svg"} processing failure never verifies or delivers the SVG`, async () => {
const { headless, svgCalls } = fakeHeadless(Buffer.from("corrupt render"));
const steps: FauxResponseStep[] = [
fauxAssistantMessage([
fauxToolCall("write_svg", { svg: SVG }, { id: "write-1" }),
...(!forced ? [fauxToolCall("render_svg", {}, { id: "render-1" })] : []),
], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Unverified claim that the picture is correct.")]),
];
const { engine, requests } = scripted(steps);
const activity: string[] = [];
const result = await runVisualMaker({ brief: "One synthetic figure." }, { engine, headless, onActivity: (line) => activity.push(line) });
assert.equal(requests.length, 2);
assert.ok(requests.every((request) => images(request).length === 0));
assert.equal(result.svg, "");
assert.match(result.notes!, /^FAILED: inspecting the final SVG failed/);
assert.match(result.notes!, /Image omitted:/);
assert.ok(!activity.some((line) => line.includes("inspecting")), "a failed image is never marked inspected");
assert.equal(svgCalls.length, forced ? 1 : 2, "an explicit processing failure leaves the final-SVG gate unsatisfied");
if (!forced) assert.match(text(requests[1]!), /RENDER ERROR.*Image omitted:/);
});
}
+6 -1
View File
@@ -28,6 +28,7 @@ import assert from "node:assert/strict";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { after, describe, it } from "node:test"; import { after, describe, it } from "node:test";
import { createCanvas } from "@napi-rs/canvas";
import { Value } from "typebox/value"; import { Value } from "typebox/value";
import { createFauxCore, fauxAssistantMessage, fauxToolCall, type FauxResponseStep, type ToolCall } from "@earendil-works/pi-ai"; import { createFauxCore, fauxAssistantMessage, fauxToolCall, type FauxResponseStep, type ToolCall } from "@earendil-works/pi-ai";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
@@ -171,7 +172,11 @@ function fakeHeadless(opts: {
if (opts.renderFailures && api.svgRenders.length <= opts.renderFailures) { if (opts.renderFailures && api.svgRenders.length <= opts.renderFailures) {
throw new Error("XML parse error"); throw new Error("XML parse error");
} }
return Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG magic // The shared native image processor decodes renders, so a signature
// alone is no longer a successful fake raster.
const canvas = createCanvas(1, 1);
canvas.getContext("2d").fillRect(0, 0, 1, 1);
return canvas.toBuffer("image/png");
}, },
}; };
return api as HeadlessApi & { demoRuns: string[]; svgRenders: string[] }; return api as HeadlessApi & { demoRuns: string[]; svgRenders: string[] };
+1 -1
View File
@@ -96,7 +96,7 @@ describe("desktop window chrome", () => {
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context); runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context);
assert.equal(bridge.platform, "win32"); assert.equal(bridge.platform, "win32");
assert.deepEqual(Object.keys(bridge).sort(), [ assert.deepEqual(Object.keys(bridge).sort(), [
"chatgpt", "checkForUpdates", "expandWindow", "getUpdateState", "getZoom", "installUpdate", "onOpenSettings", "onUpdateStatus", "onZoom", "platform", "subscriptions", "chatgpt", "checkForUpdates", "expandWindow", "getPathForFile", "getUpdateState", "getZoom", "installUpdate", "onOpenSettings", "onUpdateStatus", "onZoom", "platform", "subscriptions",
]); ]);
assert.deepEqual(Object.keys(bridge.subscriptions as object).sort(), ["cancel", "consent", "disconnect", "login", "onChange", "open", "respond", "retry", "status"]); assert.deepEqual(Object.keys(bridge.subscriptions as object).sort(), ["cancel", "consent", "disconnect", "login", "onChange", "open", "respond", "retry", "status"]);
}); });
+89
View File
@@ -0,0 +1,89 @@
import React from "react";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { parseHTML } from "linkedom";
import { FilePathChip, PathText } from "../components/file-path-chip.tsx";
import { quoteFilePath } from "../src/core/file-paths.ts";
function documentFor(node: React.ReactNode) {
return parseHTML(`<html><body>${renderToStaticMarkup(node)}</body></html>`).document;
}
describe("FilePathChip", () => {
it("shows a filename and decorative type icon with an accessible full path", () => {
const path = "/tmp/course notes/α.pdf";
const document = documentFor(<FilePathChip path={path} />);
const chip = document.body.firstElementChild!;
assert.equal(chip.tagName, "SPAN");
assert.equal(chip.textContent, "α.pdf");
assert.equal(chip.getAttribute("title"), path);
assert.equal(chip.getAttribute("role"), "img");
assert.equal(chip.getAttribute("aria-label"), path);
assert.equal(chip.querySelector("svg")!.getAttribute("aria-hidden"), "true");
assert.ok(chip.querySelector(".lucide-file-text"));
assert.equal(document.querySelector("a, button, input, [tabindex]"), null);
assert.equal(chip.getAttributeNames().some((name) => name.toLowerCase() === "contenteditable"), false);
assert.ok(chip.classList.contains("inline-flex"));
assert.ok(chip.querySelector(".truncate"));
});
it("supports atomic editor rendering without requiring an editor in transcripts", () => {
const document = documentFor(<FilePathChip path="C:\\notes\\chapter.txt" contentEditable={false} className="editor-chip" />);
const chip = document.body.firstElementChild!;
// React SSR emits contentEditable; linkedom preserves attribute case.
const editableAttribute = chip.getAttributeNames().find((name) => name.toLowerCase() === "contenteditable")!;
assert.equal(chip.getAttribute(editableAttribute), "false");
assert.ok(chip.classList.contains("editor-chip"));
assert.equal(chip.textContent, "chapter.txt");
});
it("chooses type icons without accessing files or changing their names", () => {
for (const [filename, icon] of [
["figure.PNG", "file-image"], ["lecture.mp3", "file-headphone"], ["clip.mp4", "file-play"],
["course.zip", "file-archive"], ["data.csv", "file-spreadsheet"], ["example.ts", "file-code"],
["notes.md", "file-text"], ["unknown.xyz", "file"],
]) {
const document = documentFor(<FilePathChip path={`/not/real/${filename}`} />);
assert.equal(document.body.textContent, filename);
assert.ok(document.querySelector(`.lucide-${icon}`));
}
});
it("keeps dangerous filename HTML inert in both label and title", () => {
const path = '/tmp/<img src=x onerror="alert(1)">.pdf';
const document = documentFor(<FilePathChip path={path} />);
assert.equal(document.body.textContent, '<img src=x onerror="alert(1)">.pdf');
assert.equal(document.body.firstElementChild!.getAttribute("title"), path);
assert.equal(document.querySelector("img, script, [onerror], a"), null);
});
});
describe("PathText", () => {
it("keeps ordinary text, markup-looking text and inline code completely unchanged", () => {
const text = 'Ordinary <b>prose</b>\n`code` ./relative `file:///tmp/a` & words';
const document = documentFor(<PathText text={text} />);
assert.equal(document.body.textContent, text);
assert.equal(document.body.children.length, 0);
assert.equal(renderToStaticMarkup(<PathText text="" />), "");
});
it("renders all quoted platforms, adjacent words and paths containing backticks", () => {
const paths = ["/tmp/a.pdf", "C:\\notes\\b.md", "\\\\server\\share\\c.png", "~/d`e.txt"];
const text = `before${paths.map(quoteFilePath).join(" then ")}after`;
const document = documentFor(<PathText text={text} />);
const chips = document.querySelectorAll('[role="img"]');
assert.equal(chips.length, paths.length);
assert.deepEqual(Array.from(chips, (chip) => chip.getAttribute("title")), paths);
assert.equal(document.body.textContent, "beforea.pdf then b.md then c.png then d`e.txtafter");
assert.equal(text, `before${paths.map(quoteFilePath).join(" then ")}after`, "presentation never rewrites saved text");
});
it("does not interpret HTML in ordinary text or path labels", () => {
const path = '/tmp/<img src=x onerror="alert(1)">.png';
const document = documentFor(<PathText text={`<script>alert(1)</script> ${quoteFilePath(path)}`} />);
assert.equal(document.querySelector("script, img, [onerror], a"), null);
assert.equal(document.querySelector('[role="img"]')!.getAttribute("title"), path);
assert.equal(document.body.textContent, '<script>alert(1)</script> <img src=x onerror="alert(1)">.png');
});
});
+233
View File
@@ -0,0 +1,233 @@
import React, { act } from "react";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { afterEach, test } from "node:test";
import { parseHTML } from "linkedom";
import { FilePathDropZone } from "../components/file-path-drop-zone.tsx";
import { useDictationTarget } from "../components/dictation.tsx";
import { PathInput } from "../components/path-input.tsx";
import { readPathInput, restorePathSelection } from "../components/path-input-dom.ts";
import { quoteFilePath } from "../src/core/file-paths.ts";
let cleanup: (() => Promise<void>) | undefined;
afterEach(async () => { await cleanup?.(); cleanup = undefined; });
/** No native DataTransfer/Selection in linkedom. These shims exercise React's
* real bubbling handlers and the editor's logical caret contract, not OS drag. */
async function fixture({ desktop = true, claimed = true } = {}) {
const { window: win, document: doc } = parseHTML("<html><body><div id='root'></div></body></html>");
let active: Element = doc.body;
Object.defineProperty(doc, "activeElement", { get: () => active });
const selection = {
anchorNode: null as Node | null, anchorOffset: 0, focusNode: null as Node | null, focusOffset: 0,
setBaseAndExtent(anchorNode: Node, anchorOffset: number, focusNode: Node, focusOffset: number) {
Object.assign(this, { anchorNode, anchorOffset, focusNode, focusOffset });
},
};
Object.assign(doc, { getSelection: () => selection, oninput: null });
win.HTMLElement.prototype.focus = function () { active = this; };
const resolved: File[] = [];
const backingPaths = new Map<File, string | Error>();
const appends: string[] = [];
let byteReads = 0;
let uploads = 0;
let submits = 0;
let keys = 0;
let value = "readthis";
const bridge = desktop ? { getPathForFile(file: File) {
resolved.push(file);
const result = backingPaths.get(file);
if (result instanceof Error) throw result;
return result ?? "";
} } : undefined;
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [name, next] of Object.entries({
React, window: win, document: doc, Node: win.Node, HTMLElement: win.HTMLElement, Element: win.Element,
Event: win.Event, CustomEvent: win.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true,
fetch: () => { uploads++; throw new Error("No network expected for file drops"); },
})) {
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value: next });
}
const oldBridge = Object.getOwnPropertyDescriptor(win, "lh");
Object.defineProperty(win, "lh", { configurable: true, value: bridge });
const { createRoot } = await import("react-dom/client");
const root = createRoot(doc.getElementById("root")!);
function Harness() {
useDictationTarget((text) => appends.push(text), claimed);
return <>
<aside data-testid="sidebar">Sidebar</aside>
<FilePathDropZone>
<form onSubmit={(event) => { event.preventDefault(); submits++; }}>
<PathInput data-testid="editor" value={value} onValueChange={(next) => { value = next; draw(); }} onKeyDown={() => { keys++; }} />
<button type="button" data-testid="elsewhere">Elsewhere</button>
<div data-testid="content">Content away from the input</div>
</form>
</FilePathDropZone>
</>;
}
function draw() { root.render(<Harness />); }
await act(async () => { draw(); });
cleanup = async () => {
await act(async () => { root.unmount(); });
if (oldBridge) Object.defineProperty(win, "lh", oldBridge);
else delete (win as unknown as Record<string, unknown>).lh;
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else delete (globalThis as Record<string, unknown>)[name];
}
};
const editor = doc.querySelector<HTMLElement>('[data-testid="editor"]')!;
function file(name: string, path?: string | Error) {
const result = new File(["synthetic bytes must never be read"], name, { type: "application/pdf" });
for (const method of ["text", "arrayBuffer", "stream"] as const) {
Object.defineProperty(result, method, { value: () => { byteReads++; throw new Error("No file content reads"); } });
}
if (path !== undefined) backingPaths.set(result, path);
return result;
}
function transfer(files: File[], directories: number[] = [], types = ["Files"]) {
return {
types,
files: Object.assign([...files], { item: (index: number) => files[index] ?? null }),
items: files.map((file, index) => ({ kind: "file", type: file.type, getAsFile: () => file, webkitGetAsEntry: () => ({ isDirectory: directories.includes(index), isFile: !directories.includes(index) }) })),
dropEffect: "none",
};
}
async function event(type: string, dataTransfer = transfer([]), target: Element = doc.querySelector('[data-testid="content"]')!) {
const event = Object.assign(new win.Event(type, { bubbles: true, cancelable: true }), { dataTransfer });
await act(async () => { target.dispatchEvent(event); });
return event;
}
return {
doc, editor, appends, resolved, file, transfer, event,
get value() { return value; },
get status() { return doc.querySelector('[role="status"]')?.textContent ?? ""; },
focusEditor(offset: number) {
editor.focus();
restorePathSelection(editor, { anchor: offset, focus: offset });
doc.dispatchEvent(new win.Event("selectionchange"));
},
assertPathOnly() { assert.equal(byteReads, 0); assert.equal(uploads, 0); assert.equal(submits, 0); assert.equal(keys, 0); },
};
}
test("desktop drop inserts quoted paths at the focused editor caret instead of the dictation claimant", async () => {
const f = await fixture();
f.focusEditor(4);
const paths = ["/actual/course notes.pdf", "C:\\course\\chapter`one.pdf", "\\\\host\\share\\図.png"];
const files = paths.map((path, index) => f.file(`display-name-${index}.pdf`, path));
const dropped = await f.event("drop", f.transfer(files));
assert.equal(dropped.defaultPrevented, true);
assert.equal(f.value, `read ${paths.map(quoteFilePath).join(" ")} this`);
assert.equal(readPathInput(f.editor), f.value);
assert.equal(f.doc.activeElement, f.editor);
assert.deepEqual(f.appends, [], "focused editor wins over the registered question field");
assert.deepEqual(f.resolved, files, "only Electron's full path, never File.name, enters the draft");
assert.equal(f.status, "", "successful file attachment is silent");
f.assertPathOnly();
});
test("a successful drop clears an earlier failure without replacing it with a confirmation bubble", async () => {
const f = await fixture();
await f.event("drop", f.transfer([f.file("virtual.pdf")]));
assert.match(f.status, /No local file paths were available/);
await f.event("drop", f.transfer([f.file("valid.pdf", "/existing/valid.pdf")]));
assert.deepEqual(f.appends, ["`/existing/valid.pdf`"]);
assert.equal(f.doc.querySelector('[role="status"]'), null);
f.assertPathOnly();
});
test("unfocused drop uses the dictation target registry, not the last focused editor", async () => {
const f = await fixture();
f.focusEditor(4);
f.doc.querySelector<HTMLElement>('[data-testid="elsewhere"]')!.focus();
const paths = ["~/notes one.pdf", "/tmp/notes`final`"];
await f.event("drop", f.transfer(paths.map((path) => f.file("not-a-physical-path.pdf", path))));
assert.deepEqual(f.appends, [paths.map(quoteFilePath).join(" ")]);
assert.equal(f.value, "readthis", "saved editor caret must not override current default target");
f.assertPathOnly();
});
test("browser drop prevents navigation, explains desktop-only local paths and never infers File.name", async () => {
const f = await fixture({ desktop: false });
const transfer = f.transfer([f.file("/not/a/backing/path.pdf")]);
const enter = await f.event("dragenter", transfer);
assert.equal(enter.defaultPrevented, true);
assert.match(f.doc.querySelector('[data-testid="file-path-drop-indicator"]')!.textContent!, /desktop app/);
const over = await f.event("dragover", transfer);
assert.equal(over.defaultPrevented, true);
assert.equal(transfer.dropEffect, "copy");
const drop = await f.event("drop", transfer);
assert.equal(drop.defaultPrevented, true);
assert.match(f.status, /Local file paths can only be attached in the desktop app/);
assert.match(f.status, /paste a full path instead/);
assert.equal(f.doc.querySelector('[data-testid="file-path-drop-indicator"]'), null);
assert.equal(f.value, "readthis");
assert.deepEqual(f.appends, []);
assert.deepEqual(f.resolved, []);
f.assertPathOnly();
});
test("invalid, virtual and directory items are skipped while valid backing paths stay ordered", async () => {
const f = await fixture();
const files = [
f.file("virtual.pdf"), f.file("bad.pdf", "relative.pdf"), f.file("broken.pdf", new Error("not backed")),
f.file("folder", "/directory"), f.file("valid.pdf", "/existing/valid.pdf"),
f.file("multiline.pdf", "/a\nb.pdf"), f.file("NUL.pdf", "/a\0b.pdf"), f.file("url.pdf", "file:///a.pdf"),
];
await f.event("drop", f.transfer(files, [3]));
assert.deepEqual(f.appends, ["`/existing/valid.pdf`"]);
assert.equal(f.resolved.includes(files[3]!), false, "directory metadata prevents resolving a folder");
assert.match(f.status, /Some dropped items had no available file path/);
f.assertPathOnly();
});
test("a drop with no backing paths or no destination has a truthful notice and stages nothing", async () => {
const f = await fixture({ claimed: false });
const missing = await f.event("drop", f.transfer([f.file("virtual.pdf")]));
assert.equal(missing.defaultPrevented, true);
assert.match(f.status, /No local file paths were available/);
await f.event("drop", f.transfer([f.file("valid.pdf", "/existing/valid.pdf")]));
assert.match(f.status, /Open a chat to attach file paths/);
assert.deepEqual(f.appends, []);
assert.equal(f.value, "readthis");
f.assertPathOnly();
});
test("non-file drags stay native; sidebar file drops prevent navigation without attaching", async () => {
const f = await fixture();
const plain = f.transfer([], [], ["text/plain"]);
for (const type of ["dragenter", "dragover", "drop"]) {
assert.equal((await f.event(type, plain)).defaultPrevented, false);
}
const sidebar = f.doc.querySelector('[data-testid="sidebar"]')!;
assert.equal((await f.event("drop", f.transfer([f.file("notes.pdf", "/existing/notes.pdf")]), sidebar)).defaultPrevented, true);
assert.deepEqual(f.resolved, []);
assert.deepEqual(f.appends, []);
assert.equal(f.status, "");
f.assertPathOnly();
});
test("nested enter/leave depth keeps the drop overlay until the zone is left; notice is dismissible", async () => {
const f = await fixture({ desktop: false });
const transfer = f.transfer([f.file("notes.pdf")]);
await f.event("dragenter", transfer);
await f.event("dragenter", transfer);
await f.event("dragleave", transfer);
assert.ok(f.doc.querySelector('[data-testid="file-path-drop-indicator"]'));
await f.event("dragleave", transfer);
assert.equal(f.doc.querySelector('[data-testid="file-path-drop-indicator"]'), null);
await f.event("drop", transfer);
assert.notEqual(f.status, "");
await act(async () => { f.doc.querySelector<HTMLElement>('[aria-label="Dismiss attachment notice"]')!.click(); });
assert.equal(f.status, "");
f.assertPathOnly();
});
test("production AppShell places the sidebar outside its content-only drop zone", () => {
const source = readFileSync(new URL("../components/sidebar.tsx", import.meta.url), "utf8");
const shell = source.slice(source.indexOf("export function AppShell("));
assert.match(shell, /<SessionsSidebar\s*\/>\s*<FilePathDropZone\b[^>]*>\{children\}<\/FilePathDropZone>/);
assert.doesNotMatch(shell, /<FilePathDropZone\b[^>]*>[\s\S]*<SessionsSidebar/);
});
+93
View File
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import { runInNewContext } from "node:vm";
interface PathBridge {
platform: string;
getPathForFile(file: unknown): string;
[key: string]: unknown;
}
/** Execute the actual preload in a VM, without Electron, IPC or app startup. */
function fixture(resolve: (file: unknown) => string) {
let bridge!: PathBridge;
const supplied: unknown[] = [];
const sent: unknown[][] = [];
const invoked: unknown[][] = [];
const imports: string[] = [];
const exposed: string[] = [];
const electron = {
contextBridge: { exposeInMainWorld(name: string, surface: PathBridge) { exposed.push(name); bridge = surface; } },
webUtils: { getPathForFile(file: unknown) { supplied.push(file); return resolve(file); } },
ipcRenderer: {
on() {}, removeListener() {},
send: (...args: unknown[]) => { sent.push(args); },
invoke: (...args: unknown[]) => { invoked.push(args); return Promise.resolve(); },
},
};
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), {
process: { platform: "darwin", argv: [] },
require: (name: string) => { imports.push(name); assert.equal(name, "electron"); return electron; },
});
return { bridge, supplied, sent, invoked, imports, exposed, electron };
}
describe("path-only preload bridge", () => {
it("forwards the exact supplied File identity to webUtils, returning only the backing path without IPC", () => {
const file = new File(["synthetic fixture"], "display-name.pdf", { type: "application/pdf" });
let reads = 0;
for (const method of ["text", "arrayBuffer", "stream"] as const) {
Object.defineProperty(file, method, { value: () => { reads++; throw new Error("Must not read file content"); } });
}
const fullPath = "/actual/course/notes with `backticks`.pdf";
const f = fixture((supplied) => { assert.equal(supplied, file); return fullPath; });
assert.deepEqual(f.exposed, ["lh"]);
assert.deepEqual(f.imports, ["electron"]);
assert.equal(f.bridge.getPathForFile(file), fullPath);
assert.equal(f.supplied.length, 1);
assert.equal(f.supplied[0], file);
assert.notEqual(fullPath, file.name);
assert.equal(reads, 0);
assert.deepEqual(f.sent, []);
assert.deepEqual(f.invoked, []);
});
it("keeps Electron's empty result for a File without an OS backing path, never substituting its name", () => {
const virtual = new File(["virtual"], "/misleading/full/path.pdf");
const f = fixture(() => "");
assert.equal(f.bridge.getPathForFile(virtual), "");
assert.equal(f.supplied[0], virtual);
assert.deepEqual(f.sent, []);
assert.deepEqual(f.invoked, []);
});
it("preserves webUtils errors for invalid File arguments instead of inventing a path", () => {
const invalid = { name: "/misleading/filename.pdf", path: "/not/a/capability.pdf" };
const error = new TypeError("Expected a File");
const f = fixture((supplied) => { assert.equal(supplied, invalid); throw error; });
assert.throws(() => f.bridge.getPathForFile(invalid), (caught) => caught === error);
assert.equal(f.supplied[0], invalid);
assert.deepEqual(f.sent, []);
assert.deepEqual(f.invoked, []);
});
it("adds just the narrow path resolver to the explicit surface, without exposing Electron or filesystem APIs", () => {
const f = fixture(() => "");
assert.deepEqual(Object.keys(f.bridge).sort(), [
"chatgpt", "checkForUpdates", "expandWindow", "getPathForFile", "getUpdateState", "getZoom",
"installUpdate", "onOpenSettings", "onUpdateStatus", "onZoom", "platform", "subscriptions",
]);
assert.equal(f.bridge.platform, "darwin");
for (const name of ["webUtils", "ipcRenderer", "electron", "fs", "require", "readFile", "writeFile", "uploadFile", "openPath"]) {
assert.equal(Object.hasOwn(f.bridge, name), false, name);
}
assert.notEqual(f.bridge.getPathForFile, f.electron.webUtils.getPathForFile, "webUtils itself is not exposed");
});
it("retains context isolation, sandbox and disabled Node integration in the production window", () => {
const main = readFileSync(new URL("../electron/main.cjs", import.meta.url), "utf8");
assert.match(main, /webPreferences:\s*\{\s*contextIsolation:\s*true,\s*sandbox:\s*true,\s*nodeIntegration:\s*false,/);
assert.match(main, /preload:\s*path\.join\(__dirname,\s*"preload\.cjs"\)/);
});
});
+127
View File
@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { filePathLabel, parseFilePaths, quoteFilePath } from "../src/core/file-paths.ts";
function paths(text: string) {
return parseFilePaths(text).flatMap((part) => part.kind === "path" ? [part.path] : []);
}
function assertLossless(text: string) {
const parts = parseFilePaths(text);
assert.equal(parts.map((part) => part.text).join(""), text);
let offset = 0;
for (const part of parts) {
assert.equal(part.start, offset);
assert.equal(text.slice(part.start, part.end), part.text);
offset = part.end;
}
assert.equal(offset, text.length);
}
describe("plain-text file path tokens", () => {
it("returns typed tokens with exact original text and UTF-16 offsets", () => {
const text = "📚 Read `/tmp/my notes.pdf` now.";
assert.deepEqual(parseFilePaths(text), [
{ kind: "text", text: "📚 Read ", start: 0, end: 8 },
{ kind: "path", text: "`/tmp/my notes.pdf`", path: "/tmp/my notes.pdf", start: 8, end: 27 },
{ kind: "text", text: " now.", start: 27, end: 32 },
]);
assert.deepEqual(parseFilePaths(""), []);
});
it("accepts rooted POSIX, drive, UNC and home paths without filesystem access", () => {
for (const path of [
"/tmp/notes.pdf", "/", "/does not exist/α ∂ 🧠.pdf", "//volume/file",
"C:\\Users\\Learner\\My notes.pdf", "d:/notes/測度.md", "C:\\",
"\\\\server\\share\\nested folder\\notes.pdf", "\\\\server\\share", "\\\\server/share/file.txt",
"~/Documents/notes.pdf", "~/", "/tmp/a\\b.txt", "/tmp/<>&\"'[]()#?!;:$*=|.txt",
]) {
const quoted = quoteFilePath(path);
assert.deepEqual(paths(quoted), [path]);
assertLossless(quoted);
}
});
it("recognizes manual and pasted quoted paths anywhere, including adjacent prose", () => {
const text = "word`/a`word (`~/b`) [`C:/c`](x) <`/d`> /not/quoted";
assert.deepEqual(paths(text), ["/a", "~/b", "C:/c", "/d"]);
assertLossless(text);
});
it("does not reinterpret ordinary prose, relative paths, URLs or code", () => {
for (const text of [
"Read /tmp/file.pdf and ~/notes", "`hello world`", "`a + b`", "`notes.pdf`", "`./notes.pdf`",
"`../notes.pdf`", "`C:notes.pdf`", "`~someone/notes.pdf`", "`file:///tmp/notes.pdf`",
"`https://example.test/a.pdf`", "`\\notes.pdf`", "`\\\\server`", "`const p = '/a';`",
"` /leading space`", "``code `/a` here``", "```\n/a\n```", "```\n`/a`\n```",
]) {
assert.deepEqual(paths(text), [], text);
assertLossless(text);
}
});
it("matches complete equal delimiter runs, never their partial boundaries", () => {
for (const text of ["`/a", "``/a`", "`/a``", "```/a``"]) {
assert.deepEqual(paths(text), []);
assertLossless(text);
}
// Other-width runs are legal filename characters, not partial closers.
assert.deepEqual(paths("``/a```/b``"), ["/a```/b"]);
assert.deepEqual(paths("`/a``/b`"), ["/a``/b"]);
assert.deepEqual(paths("``not code closed? `/a`"), ["/a"]);
assert.deepEqual(paths("```/a```"), ["/a"]);
});
it("preserves backticks with variable delimiters and one-space padding", () => {
assert.equal(quoteFilePath("/tmp/a`b.txt"), "``/tmp/a`b.txt``");
assert.equal(quoteFilePath("/tmp/a`"), "`` /tmp/a` ``");
assert.equal(quoteFilePath("/tmp/a```"), "```` /tmp/a``` ````");
for (const path of ["/tmp/a`", "/tmp/a``b`", "/tmp/`/```/``", "/tmp/a `", "/tmp/a ", "/tmp/ "]) {
const text = `before ${quoteFilePath(path)} after`;
assert.deepEqual(paths(text), [path]);
assertLossless(text);
}
assert.deepEqual(paths("`` /tmp/a` ``"), ["/tmp/a`"]);
assert.deepEqual(paths("` /tmp/a `"), ["/tmp/a "]);
assert.deepEqual(paths("` /tmp/a `"), [], "strip one padding space only");
});
it("rejects all C0/C1 controls, multiline paths and Unicode line separators", () => {
const controls = [
...Array.from({ length: 32 }, (_, i) => String.fromCharCode(i)),
...Array.from({ length: 33 }, (_, i) => String.fromCharCode(127 + i)),
"\u2028", "\u2029",
];
for (const control of controls) {
const path = `/tmp/a${control}b.pdf`;
assert.deepEqual(paths(`\`${path}\``), []);
assert.throws(() => quoteFilePath(path), TypeError);
}
for (const path of ["", "relative", "file:///tmp/a", "C:a", "\\\\server"]) {
assert.throws(() => quoteFilePath(path), TypeError);
}
});
it("round-trips many combinations of legal filename punctuation and whitespace", () => {
for (const piece of ["`", "``", " ``` ", " ", " ", "<script>", "[link](x)", "a\\b", "Ω📚"]) {
for (const suffix of ["", "`", "``", " ", ".pdf"]) {
const path = `/tmp/${piece}${suffix}`;
const quoted = quoteFilePath(path);
assert.deepEqual(paths(quoted), [path]);
assertLossless(quoted);
}
}
});
});
describe("filePathLabel", () => {
it("extracts basenames for each platform without normalizing the path", () => {
for (const [path, label] of [
["/tmp/a.pdf", "a.pdf"], ["~/測度 theory.md", "測度 theory.md"],
["C:\\Users\\a.pdf", "a.pdf"], ["C:/Users/a.pdf", "a.pdf"],
["\\\\server\\share\\a.pdf", "a.pdf"], ["/tmp/a\\b.txt", "a\\b.txt"],
["/tmp/folder/", "folder"], ["/", "/"], ["~/", "~/"], ["C:\\", "C:\\"],
["/tmp/a`b", "a`b"],
]) assert.equal(filePathLabel(path!), label);
});
});
+357
View File
@@ -0,0 +1,357 @@
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { test } from "node:test";
import { Worker } from "node:worker_threads";
import { createCanvas } from "@napi-rs/canvas";
import * as photon from "@silvia-odwyer/photon-node";
import {
detectSupportedImageMimeType,
detectSupportedImageMimeTypeFromFile,
processImage,
} from "../src/agent/image-processing/index.ts";
import { applyExifOrientation } from "../src/agent/image-processing/exif-orientation.mjs";
import { resizeImageInProcess } from "../src/agent/image-processing/resize-core.mjs";
import { resizeImage } from "../src/agent/image-processing/resize.ts";
// Synthetic, offline fixtures only. Canvas generates inputs; Photon is the
// actual processing implementation and supplies independent encoding oracles.
function png(width = 96, height = 96, noise = false): Buffer {
const canvas = createCanvas(width, height);
const context = canvas.getContext("2d");
if (noise) {
const pixels = context.createImageData(width, height);
let state = 0x12345678;
for (let i = 0; i < pixels.data.length; i += 4) {
for (let channel = 0; channel < 3; channel++) {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
pixels.data[i + channel] = state >>> 24;
}
pixels.data[i + 3] = 255;
}
context.putImageData(pixels, 0, 0);
} else {
context.fillStyle = "#33aadd";
context.fillRect(0, 0, width, height);
}
return canvas.toBuffer("image/png");
}
function encodings(bytes: Uint8Array, width: number, height: number, qualities = [80, 85, 70, 55, 40]) {
const image = photon.PhotonImage.new_from_byteslice(bytes);
const resized = photon.resize(image, width, height, photon.SamplingFilter.Lanczos3);
try {
return [
{ mimeType: "image/png", data: Buffer.from(resized.get_bytes()).toString("base64") },
...qualities.map((quality) => ({ mimeType: "image/jpeg", data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64") })),
];
} finally {
resized.free();
image.free();
}
}
function bmp(): Buffer {
// A 2x1, uncompressed, 24-bit Windows BMP (padded scanline).
const bytes = Buffer.alloc(62);
bytes.write("BM");
bytes.writeUInt32LE(bytes.length, 2);
bytes.writeUInt32LE(54, 10);
bytes.writeUInt32LE(40, 14);
bytes.writeInt32LE(2, 18);
bytes.writeInt32LE(1, 22);
bytes.writeUInt16LE(1, 26);
bytes.writeUInt16LE(24, 28);
bytes.writeUInt32LE(8, 34);
bytes.set([0, 0, 255, 0, 255, 0], 54);
return bytes;
}
function exifJpeg(orientation: number): Buffer {
const image = photon.PhotonImage.new_from_byteslice(png(12, 8));
let jpeg: Buffer;
try {
jpeg = Buffer.from(image.get_bytes_jpeg(80));
} finally {
image.free();
}
const exif = Buffer.alloc(36);
exif.set([0xff, 0xe1, 0, 34]);
exif.write("Exif\0\0", 4, "binary");
exif.write("II", 10);
exif.writeUInt16LE(42, 12);
exif.writeUInt32LE(8, 14);
exif.writeUInt16LE(1, 18);
exif.writeUInt16LE(0x0112, 20);
exif.writeUInt16LE(3, 22);
exif.writeUInt32LE(1, 24);
exif.writeUInt16LE(orientation, 28);
return Buffer.concat([jpeg.subarray(0, 2), exif, jpeg.subarray(2)]);
}
test("already-fit image bytes are unchanged, MIME aliases normalize, and transfer leaves input intact", async () => {
const bytes = png();
const original = Buffer.from(bytes);
const result = await processImage(bytes, " Image/PNG; charset=binary ");
assert.deepEqual(result, { ok: true, data: bytes.toString("base64"), mimeType: "image/png", hints: [] });
assert.deepEqual(bytes, original);
const jpeg = exifJpeg(1);
assert.deepEqual(await processImage(jpeg, "image/jpg"), { ok: true, data: jpeg.toString("base64"), mimeType: "image/jpeg", hints: [] });
});
test("native default dimensions are 2000x2000 and dimension hints use the native mapping", async () => {
const bytes = png(12000, 24);
const result = await processImage(bytes, "image/png");
assert.ok(result.ok);
assert.equal(result.mimeType, "image/png");
assert.deepEqual(result.hints, ["[Image: original 12000x24, displayed at 2000x4. Multiply coordinates by 6.00 to map to original image.]"]);
const decoded = photon.PhotonImage.new_from_byteslice(Buffer.from(result.data, "base64"));
try {
assert.equal(decoded.get_width(), 2000);
assert.equal(decoded.get_height(), 4);
} finally { decoded.free(); }
const tall = await resizeImage(png(24, 12000), "image/png");
assert.ok(tall);
assert.equal(tall.width, 4);
assert.equal(tall.height, 2000);
});
test("model dimensions use sequential rounding, not a one-step scale", async () => {
const result = await resizeImage(png(301, 503), "image/png", { maxWidth: 101, maxHeight: 100 });
assert.ok(result);
// 301x503 -> 101x169 -> 60x100.
assert.equal(result.width, 60);
assert.equal(result.height, 100);
assert.equal(result.data, encodings(png(301, 503), 60, 100)[0]!.data);
const rounding = await resizeImage(png(50, 77), "image/png", { maxWidth: 10, maxHeight: 10 });
assert.ok(rounding);
assert.equal(rounding.width, 7); // round(100 / round(77 * 10 / 50)); one-step gives 6.
assert.equal(rounding.height, 10);
});
test("native cap is strict and measures base64 bytes, not binary bytes", async () => {
const source = png(24, 24);
// A valid PNG with ignored trailing bytes: under 4.5 MiB binary, exactly
// 4.5 MiB base64. At equality the unchanged fast path must NOT run.
const padded = Buffer.alloc((4.5 * 1024 * 1024 / 4) * 3);
source.copy(padded);
assert.ok(padded.length < 4.5 * 1024 * 1024);
assert.equal(padded.toString("base64").length, 4.5 * 1024 * 1024);
const exact = await processImage(padded, "image/png");
assert.ok(exact.ok);
assert.notEqual(exact.data, padded.toString("base64"));
assert.ok(exact.data.length < 4.5 * 1024 * 1024);
assert.equal(exact.hints.length, 1, "native re-encoding is marked resized even at identical dimensions");
const justBelow = padded.subarray(0, padded.length - 3);
const below = await processImage(justBelow, "image/png");
assert.ok(below.ok);
assert.equal(below.data, justBelow.toString("base64"));
assert.deepEqual(below.hints, []);
});
test("PNG wins first under cap even when JPEG is smaller", async () => {
const bytes = png(96, 96, true);
const candidates = encodings(bytes, 95, 95);
assert.ok(candidates[0]!.data.length > candidates[1]!.data.length);
const result = await resizeImage(bytes, "image/png", { maxWidth: 95, maxBytes: candidates[0]!.data.length + 1 });
assert.ok(result);
assert.equal(result.mimeType, "image/png");
assert.equal(result.data, candidates[0]!.data);
});
test("JPEG quality selection is native first-fit [80,85,70,55,40], not smallest output", async () => {
const bytes = png(96, 96, true);
const candidates = encodings(bytes, 96, 96);
assert.ok(candidates[0]!.data.length > candidates[2]!.data.length);
assert.ok(candidates[2]!.data.length > candidates[1]!.data.length);
assert.ok(candidates[1]!.data.length > candidates[3]!.data.length);
const at80 = await resizeImage(bytes, "image/png", { maxBytes: candidates[1]!.data.length + 1 });
assert.ok(at80);
assert.equal(at80.data, candidates[1]!.data);
// Strict equality excludes 80; 85 is larger, so 70 is the next fit.
const at70 = await resizeImage(bytes, "image/png", { maxBytes: candidates[1]!.data.length });
assert.ok(at70);
assert.equal(at70.data, candidates[3]!.data);
assert.equal(at70.width, 96);
assert.equal(at70.height, 96);
for (const [index, quality] of [[4, 55], [5, 40]] as const) {
const candidate = candidates[index]!;
const cap = candidate.data.length + 1;
assert.ok(candidates.slice(0, index).every((earlier) => earlier.data.length >= cap));
const lowerQuality = await resizeImage(bytes, "image/png", { maxBytes: cap });
assert.ok(lowerQuality);
assert.equal(lowerQuality.data, candidate.data, `native quality ${quality} wins when preceding candidates are over cap`);
assert.equal(lowerQuality.width, 96);
assert.equal(lowerQuality.height, 96);
}
const override = await resizeImage(bytes, "image/png", { jpegQuality: 85, maxBytes: candidates[2]!.data.length + 1 });
assert.ok(override);
assert.equal(override.data, candidates[2]!.data, "caller quality precedes the native remaining quality steps");
});
test("all encodings over cap reduce each dimension by floor(0.75), eventually omitting at 1x1", async () => {
const bytes = png(97, 95, true);
const full = encodings(bytes, 97, 95);
const next = encodings(bytes, 72, 71);
const maxBytes = Math.min(...full.map((candidate) => candidate.data.length));
const expected = next.find((candidate) => candidate.data.length < maxBytes);
assert.ok(expected, "synthetic fixture fits on the first reduced dimensions");
const result = await resizeImage(bytes, "image/png", { maxBytes });
assert.ok(result);
assert.equal(result.width, 72);
assert.equal(result.height, 71);
assert.equal(result.mimeType, expected.mimeType);
assert.equal(result.data, expected.data);
assert.equal(await resizeImage(bytes, "image/png", { maxBytes: 1 }), null);
const omitted = await processImage(bytes, "image/png", { resizeOptions: { maxBytes: 1 } });
assert.deepEqual(omitted, { ok: false, message: "[Image omitted: could not be resized below the inline image size limit.]" });
});
test("BMP converts to PNG and reports the native conversion hint", async () => {
const bytes = bmp();
assert.equal(detectSupportedImageMimeType(bytes), "image/bmp");
const result = await processImage(bytes, "IMAGE/BMP");
assert.ok(result.ok);
assert.equal(result.mimeType, "image/png");
assert.equal(detectSupportedImageMimeType(Buffer.from(result.data, "base64")), "image/png");
assert.deepEqual(result.hints, ["[Image converted from image/bmp to image/png.]"]);
const decoded = photon.PhotonImage.new_from_byteslice(Buffer.from(result.data, "base64"));
try {
assert.equal(decoded.get_width(), 2);
assert.equal(decoded.get_height(), 1);
} finally { decoded.free(); }
});
test("EXIF-oriented dimensions govern resizing, with native unchanged-byte behavior when already fit", async () => {
for (const orientation of [2, 3, 4, 5, 6, 7, 8]) {
const bytes = exifJpeg(orientation);
const untouched = await resizeImage(bytes, "image/jpeg");
assert.ok(untouched);
assert.equal(untouched.data, bytes.toString("base64"));
assert.equal(untouched.originalWidth, orientation >= 5 ? 8 : 12);
assert.equal(untouched.originalHeight, orientation >= 5 ? 12 : 8);
const resized = await resizeImage(bytes, "image/jpeg", { maxWidth: 6, maxHeight: 6 });
assert.ok(resized);
assert.equal(resized.width, orientation >= 5 ? 4 : 6);
assert.equal(resized.height, orientation >= 5 ? 6 : 4);
}
});
test("EXIF mirrors and rotations preserve exact pixel placement for JPEG and both WebP EXIF layouts", () => {
const expected = [
[1, 2, 3, 4, 5, 6], // unchanged
[3, 2, 1, 6, 5, 4], // horizontal mirror
[6, 5, 4, 3, 2, 1], // 180 degrees
[4, 5, 6, 1, 2, 3], // vertical mirror
[1, 4, 2, 5, 3, 6], // transpose
[4, 1, 5, 2, 6, 3], // 90 degrees clockwise
[6, 3, 5, 2, 4, 1], // transverse
[3, 6, 2, 5, 1, 4], // 90 degrees counterclockwise
];
for (let orientation = 1; orientation <= 8; orientation++) {
const jpeg = exifJpeg(orientation);
// Only metadata is needed here: decoding is covered by the resize test.
const exif = jpeg.subarray(6, 38);
for (const metadata of [jpeg, ...[exif, exif.subarray(6)].map((data) => {
// Odd-size RIFF chunk before EXIF verifies native even-byte padding.
const webp = Buffer.alloc(12 + 10 + 8 + data.length);
webp.write("RIFF", 0);
webp.writeUInt32LE(webp.length - 8, 4);
webp.write("WEBP", 8);
webp.write("JUNK", 12);
webp.writeUInt32LE(1, 16);
webp.write("EXIF", 22);
webp.writeUInt32LE(data.length, 26);
data.copy(webp, 30);
return webp;
})]) {
const pixels = Uint8Array.from([1, 2, 3, 4, 5, 6].flatMap((value) => [value, 0, 0, 255]));
const raw = new photon.PhotonImage(pixels, 3, 2);
const oriented = applyExifOrientation(photon, raw, metadata);
try {
assert.equal(oriented.get_width(), orientation >= 5 ? 2 : 3);
assert.equal(oriented.get_height(), orientation >= 5 ? 3 : 2);
assert.deepEqual(Array.from(oriented.get_raw_pixels()).filter((_, index) => index % 4 === 0), expected[orientation - 1]);
} finally {
if (oriented !== raw) oriented.free();
raw.free();
}
}
}
});
test("corrupt supported input is omitted, unsupported conversion fails, and explicit autoResizeImages=false retains native bypass", async () => {
const bytes = Buffer.from("not an image");
assert.deepEqual(await processImage(bytes, "image/png"), { ok: false, message: "[Image omitted: could not be resized below the inline image size limit.]" });
assert.deepEqual(await processImage(bytes, "image/bmp"), { ok: false, message: "[Image omitted: could not be converted to a supported inline image format.]" });
assert.deepEqual(await processImage(bytes, "image/png", { autoResizeImages: false }), { ok: true, data: bytes.toString("base64"), mimeType: "image/png", hints: [] });
const wide = png(12000, 24);
const bypass = await processImage(wide, "image/png", { autoResizeImages: false });
assert.ok(bypass.ok);
assert.equal(bypass.data, wide.toString("base64"));
const converted = await processImage(bmp(), "image/bmp", { autoResizeImages: false });
assert.ok(converted.ok);
assert.equal(converted.mimeType, "image/png");
});
test("MIME sniffing matches native supported signatures and rejects APNG/JPEG-LS/malformed BMP", async () => {
assert.equal(detectSupportedImageMimeType(png()), "image/png");
assert.equal(detectSupportedImageMimeType(Buffer.from([0xff, 0xd8, 0xff, 0xe0])), "image/jpeg");
assert.equal(detectSupportedImageMimeType(Buffer.from([0xff, 0xd8, 0xff, 0xf7])), null);
assert.equal(detectSupportedImageMimeType(Buffer.from("GIF89a")), "image/gif");
assert.equal(detectSupportedImageMimeType(Buffer.from("GIF87a")), "image/gif");
assert.equal(detectSupportedImageMimeType(Buffer.from("RIFFxxxxWEBP")), "image/webp");
assert.equal(detectSupportedImageMimeType(Buffer.from("BM")), null);
assert.equal(detectSupportedImageMimeType(Buffer.from("not an image")), null);
const truncatedPng = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
assert.equal(detectSupportedImageMimeType(truncatedPng), null);
// MIME sniffing, like Pi, walks chunk headers without checking CRCs.
const source = png(2, 2);
const acTL = Buffer.alloc(20);
acTL.writeUInt32BE(8, 0);
acTL.write("acTL", 4);
const apng = Buffer.concat([source.subarray(0, 33), acTL, source.subarray(33)]);
assert.equal(detectSupportedImageMimeType(apng), null);
const badBmp = bmp();
badBmp.writeUInt16LE(2, 26);
assert.equal(detectSupportedImageMimeType(badBmp), null);
const dir = await mkdtemp(path.join(os.tmpdir(), "lh-image-processing-"));
try {
await writeFile(path.join(dir, "renamed.txt"), source);
await writeFile(path.join(dir, "animated.png"), apng);
assert.equal(await detectSupportedImageMimeTypeFromFile(path.join(dir, "renamed.txt")), "image/png");
assert.equal(await detectSupportedImageMimeTypeFromFile(path.join(dir, "animated.png")), null);
await assert.rejects(detectSupportedImageMimeTypeFromFile(path.join(dir, "missing")), { code: "ENOENT" });
} finally { await rm(dir, { recursive: true, force: true }); }
});
test("unbundled Node worker responds with tagged output and matches the in-process fallback", async () => {
const bytes = png(96, 96, true);
const options = { maxWidth: 64, maxHeight: 64, maxBytes: 4000 };
const worker = new Worker(path.join(process.cwd(), "src/agent/image-processing/resize-worker.mjs"));
try {
const reply = new Promise<{ type: string; result: unknown }>((resolve, reject) => {
worker.once("message", resolve);
worker.once("error", reject);
});
worker.postMessage({ inputBytes: new Uint8Array(bytes), mimeType: "image/png", options });
assert.deepEqual(await reply, { type: "pi:image-resize-response", result: await resizeImageInProcess(bytes, "image/png", options) });
assert.deepEqual(await resizeImage(bytes, "image/png", options), await resizeImageInProcess(bytes, "image/png", options));
} finally { await worker.terminate(); }
});
test("missing cwd-relative worker falls back in-process without altering the caller bytes", async () => {
const cwd = process.cwd();
const dir = await mkdtemp(path.join(os.tmpdir(), "lh-image-worker-fallback-"));
const bytes = png();
const options = { maxWidth: 64, maxHeight: 64 };
const expected = await resizeImageInProcess(bytes, "image/png", options);
try {
process.chdir(dir);
assert.deepEqual(await resizeImage(bytes, "image/png", options), expected);
} finally {
process.chdir(cwd);
await rm(dir, { recursive: true, force: true });
}
});
+629
View File
@@ -0,0 +1,629 @@
import React, { act } from "react";
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { parseHTML } from "linkedom";
import { renderToStaticMarkup } from "react-dom/server";
import { PathInput, INSERT_FILE_PATHS_EVENT } from "../components/path-input.tsx";
import {
PATH_CARET_ATTRIBUTE, PATH_SOURCE_ATTRIBUTE, PathInputHistory, adjacentPathRange, atomicPathRange,
insertFilePaths, normalizePathEdit, normalizePathInputValue, pathInputOffset, pathInputPoint, pathInputStaticRange, readPathInput,
readPathSelection, renderPathInput, replacePathSelection, restorePathSelection,
} from "../components/path-input-dom.ts";
import { quoteFilePath } from "../src/core/file-paths.ts";
function editor() {
const { document } = parseHTML("<html><body><div id='editor'></div></body></html>");
return document.getElementById("editor")!;
}
test("DOM projection is lossless across whitespace, Unicode and all rooted path forms", () => {
const root = editor();
const paths = ["/a/notes one.pdf", "C:\\a\\notes.pdf", "\\\\host\\share\\sheet.csv", "~/notes`final`.pdf"];
const value = ` 👩🏽‍💻\t中\n${paths.map(quoteFilePath).join("\n\t")}\n \n`;
const hosts = renderPathInput(root, value);
assert.equal(hosts.length, paths.length);
for (const { host } of hosts) {
host.textContent = "abbreviated.pdf";
assert.equal(host.getAttribute("contenteditable"), "false");
}
assert.equal(readPathInput(root), value);
assert.equal(root.dataset.empty, "false");
renderPathInput(root, "");
assert.equal(readPathInput(root), "");
assert.equal(root.dataset.empty, "true");
});
test("logical offsets round-trip across text, adjacent chips, line breaks and reversed selections", () => {
const root = editor();
const value = `🙂A ${quoteFilePath("/one")} ${quoteFilePath("/two")}\nend`;
renderPathInput(root, value);
for (let offset = 0; offset <= value.length; offset++) {
const point = pathInputPoint(root, offset);
const mapped = pathInputOffset(root, point.node, point.offset)!;
const token = Array.from(root.querySelectorAll(`[${PATH_SOURCE_ATTRIBUTE}]`)).find((host) => {
const start = value.indexOf(host.getAttribute(PATH_SOURCE_ATTRIBUTE)!);
return offset > start && offset < start + host.getAttribute(PATH_SOURCE_ATTRIBUTE)!.length;
});
if (!token) assert.equal(mapped, offset, `offset ${offset}`);
else {
const raw = token.getAttribute(PATH_SOURCE_ATTRIBUTE)!;
const start = value.indexOf(raw);
assert.ok(mapped === start || mapped === start + raw.length);
}
}
assert.equal(pathInputOffset(root, root.ownerDocument.body, 0), null);
});
test("BR/block fallback never reads visual labels or normalizes ordinary spaces", () => {
const root = editor();
root.innerHTML = "<div> a </div><div><br></div><div>b</div>";
assert.equal(readPathInput(root), " a \n\nb");
root.innerHTML = "<br>";
assert.equal(readPathInput(root), "");
root.innerHTML = "a<br>b";
assert.equal(readPathInput(root), "a\nb");
});
test("trailing newline caret placeholder is presentation-only and logical end maps before it", () => {
const root = editor();
for (const value of ["\n", "\n\n", "one\n", "one\n\n", "one\n\n\n"]) {
renderPathInput(root, value);
assert.equal(root.querySelectorAll(`[${PATH_CARET_ATTRIBUTE}]`).length, 1);
assert.equal(root.lastElementChild?.tagName, "BR");
assert.equal(root.lastElementChild?.getAttribute("aria-hidden"), "true");
assert.equal(root.textContent, value);
assert.equal(readPathInput(root), value);
const point = pathInputPoint(root, value.length);
assert.equal(point.node, root);
assert.equal(point.offset, root.childNodes.length - 1);
assert.equal(pathInputOffset(root, point.node, point.offset), value.length);
assert.equal(pathInputOffset(root, root.lastChild!, 0), value.length);
}
for (const value of ["", "one", "one\ntwo"]) {
renderPathInput(root, value);
assert.equal(root.querySelector(`[${PATH_CARET_ATTRIBUTE}]`), null);
assert.equal(readPathInput(root), value);
}
});
test("range replacement/deletion is atomic and leaves neighboring whitespace untouched", () => {
const token = quoteFilePath("/course/notes.pdf");
const value = `a ${token} b`;
assert.deepEqual(atomicPathRange(value, { anchor: 5, focus: 1 }), { start: 1, end: 2 + token.length });
assert.equal(replacePathSelection(value, { anchor: 3, focus: 8 }, "").value, "a b");
assert.deepEqual(adjacentPathRange(value, { anchor: 2, focus: 2 }, "forward"), { anchor: 2, focus: 2 + token.length });
assert.deepEqual(adjacentPathRange(value, { anchor: 2 + token.length, focus: 2 + token.length }, "backward"), { anchor: 2, focus: 2 + token.length });
assert.equal(adjacentPathRange(value, { anchor: 1, focus: 1 }, "forward"), null);
});
test("file insertion uses full quoted sources at saved ranges with separating spaces", () => {
const next = insertFilePaths("readthis", { anchor: 4, focus: 4 }, ["/a b.pdf", "C:\\a\\x.txt"]);
assert.equal(next.value, "read `/a b.pdf` `C:\\a\\x.txt` this");
assert.equal(next.selection.anchor, next.value.length - 4);
assert.equal(insertFilePaths("a\n", { anchor: 2, focus: 2 }, ["~/x"]).value, "a\n`~/x`");
assert.throws(() => insertFilePaths("", { anchor: 0, focus: 0 }, ["relative.txt"]), TypeError);
});
test("owned history restores values and directional selections; a new edit discards redo", () => {
const history = new PathInputHistory();
const a = { value: "a", selection: { anchor: 1, focus: 0 } };
const b = { value: "ab", selection: { anchor: 2, focus: 2 } };
history.push(a);
assert.deepEqual(history.undo(b), a);
assert.deepEqual(history.redo(a), b);
history.undo(b);
history.push(a);
assert.equal(history.redo(b), null);
history.reset();
assert.equal(history.undo(b), null);
});
test("textbox exposes accessibility, placeholder, global-drop markers and requested sizing", () => {
const html = renderToStaticMarkup(<PathInput value="" onValueChange={() => {}} placeholder="Ask" aria-label="Message" defaultDropTarget className="min-h-24 max-h-64" />);
for (const pattern of [/role="textbox"/, /aria-multiline="true"/, /aria-label="Message"/, /data-file-path-input=""/, /data-file-path-default=""/, /data-placeholder="Ask"/, /min-h-24 max-h-64/]) assert.match(html, pattern);
});
test("SSR preserves the full escaped initial draft without waiting for client effects", () => {
const draft = " Restored <reasoning> & notes\n`/course/full notes.pdf`\n ";
const html = renderToStaticMarkup(<PathInput value={draft} onValueChange={() => assert.fail("SSR cannot emit changes")} placeholder="Reasoning" />);
const { document } = parseHTML(`<html><body>${html}</body></html>`);
const field = document.querySelector('[data-file-path-input]')!;
assert.equal(field.textContent, draft);
assert.equal(field.getAttribute("contenteditable") ?? field.getAttribute("contentEditable"), "true");
assert.equal(field.getAttribute("data-empty"), "false");
assert.equal(field.querySelector("reasoning"), null);
assert.match(html, /&lt;reasoning&gt;/);
const single = renderToStaticMarkup(<PathInput singleLine value={"one\r\ntwo"} onValueChange={() => {}} />);
assert.equal(parseHTML(`<html><body>${single}</body></html>`).document.body.textContent, "onetwo");
});
test("textbox accessible name falls back to placeholder and preserves explicit aria-label", () => {
const fallback = renderToStaticMarkup(<PathInput value="" onValueChange={() => {}} placeholder="Your reasoning" />);
assert.match(fallback, /aria-label="Your reasoning"/);
const explicit = renderToStaticMarkup(<PathInput value="" onValueChange={() => {}} placeholder="Your reasoning" aria-label="Custom answer" />);
assert.match(explicit, /aria-label="Custom answer"/);
assert.doesNotMatch(explicit, /aria-label="Your reasoning"/);
});
let cleanup: (() => Promise<void>) | undefined;
afterEach(async () => { await cleanup?.(); cleanup = undefined; });
/** linkedom has no Selection/default editing actions. The shim tests our offset
* contract, NOT browser caret navigation or the native IME implementation. */
async function fixture(initial: string, singleLine = false) {
const { window: win, document: doc } = parseHTML("<html><body><div id='root'></div><button id='elsewhere'></button></body></html>");
let active: Element = doc.body;
Object.defineProperty(doc, "activeElement", { get: () => active });
const selection = {
anchorNode: null as Node | null, anchorOffset: 0, focusNode: null as Node | null, focusOffset: 0,
setBaseAndExtent(anchorNode: Node, anchorOffset: number, focusNode: Node, focusOffset: number) {
Object.assign(this, { anchorNode, anchorOffset, focusNode, focusOffset });
},
};
Object.assign(doc, { getSelection: () => selection, oninput: null });
win.HTMLElement.prototype.focus = function () { active = this; };
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [name, value] of Object.entries({ React, window: win, document: doc, HTMLElement: win.HTMLElement, Element: win.Element, Event: win.Event, IS_REACT_ACT_ENVIRONMENT: true })) {
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
}
const { createRoot } = await import("react-dom/client");
const reactRoot = createRoot(doc.getElementById("root")!);
const ref = React.createRef<HTMLDivElement>();
let value = initial;
const writes: string[] = [];
let keys = 0;
function draw() {
reactRoot.render(<PathInput ref={ref} singleLine={singleLine} value={value} onValueChange={(next) => { writes.push(next); value = next; draw(); }} onKeyDown={() => { keys++; }} />);
}
await act(async () => { draw(); });
cleanup = async () => {
await act(async () => { reactRoot.unmount(); });
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else delete (globalThis as Record<string, unknown>)[name];
}
};
const root = ref.current!;
root.focus();
function select(anchor: number, focus = anchor) {
restorePathSelection(root, { anchor, focus });
doc.dispatchEvent(new win.Event("selectionchange"));
}
async function event(type: string, properties: Record<string, unknown> = {}) {
const event = new win.Event(type, { bubbles: true, cancelable: true });
Object.assign(event, properties);
await act(async () => { root.dispatchEvent(event); });
return event;
}
return {
root, doc, select, event, writes, selection,
get value() { return value; }, get keys() { return keys; },
async external(next: string) { await act(async () => { value = next; draw(); }); },
async nativeText(next: string, inputType = "insertText") {
await event("beforeinput", { inputType, isComposing: false });
root.replaceChildren(doc.createTextNode(next));
select(next.length);
await event("input", { inputType });
},
};
}
test("native typing, token insertion/deletion and undo/redo share one history", async () => {
const f = await fixture("a");
f.select(1);
await f.nativeText("ab");
await f.event(INSERT_FILE_PATHS_EVENT, { detail: ["/notes.pdf"] });
assert.equal(f.value, "ab `/notes.pdf`");
assert.match(f.root.textContent!, /notes.pdf/);
await f.event("keydown", { key: "Backspace" });
assert.equal(f.value, "ab ");
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "ab `/notes.pdf`");
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "ab");
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "a");
await f.event("beforeinput", { inputType: "historyRedo" });
assert.equal(f.value, "ab");
});
test("Cmd+Backspace delegates line extent to beforeinput instead of deleting just the final chip", async () => {
const source = "Explain `/tmp/notes.pdf`";
const f = await fixture(source);
f.select(source.length);
const key = await f.event("keydown", { key: "Backspace", metaKey: true });
assert.equal(key.defaultPrevented, false);
assert.equal(f.value, source);
const deletion = await f.event("beforeinput", {
inputType: "deleteSoftLineBackward",
getTargetRanges: () => [{ startContainer: f.root.firstChild!, startOffset: 0, endContainer: f.root, endOffset: f.root.childNodes.length }],
});
assert.equal(deletion.defaultPrevented, true);
assert.equal(f.value, "");
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, source);
assert.deepEqual(readPathSelection(f.root), { anchor: source.length, focus: source.length });
});
test("native word and soft/hard line deletion ranges retain mixed text/chip extents in both directions", async () => {
const source = "keep old `/tmp/notes.pdf` tail\nnext";
const f = await fixture(source);
const lineEnd = source.indexOf("\n");
const chipEnd = source.indexOf(" tail");
const cases = [
["deleteWordBackward", 5, lineEnd], ["deleteWordForward", 5, chipEnd],
["deleteSoftLineBackward", 0, lineEnd], ["deleteSoftLineForward", 5, lineEnd],
["deleteHardLineBackward", 0, chipEnd], ["deleteHardLineForward", 5, lineEnd],
] as const;
for (const [inputType, start, end] of cases) {
await f.external(source);
const backward = inputType.endsWith("Backward");
f.select(backward ? end : start);
const key = await f.event("keydown", { key: backward ? "Backspace" : "Delete", ctrlKey: true });
assert.equal(key.defaultPrevented, false, inputType);
assert.equal(f.value, source);
const a = pathInputPoint(f.root, start);
const b = pathInputPoint(f.root, end);
const deletion = await f.event("beforeinput", {
inputType,
getTargetRanges: () => [{ startContainer: a.node, startOffset: a.offset, endContainer: b.node, endOffset: b.offset }],
});
assert.equal(deletion.defaultPrevented, true, inputType);
assert.equal(f.value, source.slice(0, start) + source.slice(end), inputType);
assert.deepEqual(readPathSelection(f.root), { anchor: start, focus: start });
}
});
test("deleteEntireSoftLine honors the browser extent and never substitutes a chip when ranges are absent", async () => {
const source = "before\nExplain `/tmp/notes.pdf` tail\nafter";
const f = await fixture(source);
const start = source.indexOf("Explain");
const end = source.indexOf("\nafter");
f.select(source.indexOf("`")); // Adjacent to a chip, but not a chip-only command.
for (const getTargetRanges of [undefined, () => []]) {
const missing = await f.event("beforeinput", { inputType: "deleteEntireSoftLine", getTargetRanges });
assert.equal(missing.defaultPrevented, false);
assert.equal(f.value, source);
}
const a = pathInputPoint(f.root, start);
const b = pathInputPoint(f.root, end);
const deletion = await f.event("beforeinput", {
inputType: "deleteEntireSoftLine",
getTargetRanges: () => [{ startContainer: a.node, startOffset: a.offset, endContainer: b.node, endOffset: b.offset }],
});
assert.equal(deletion.defaultPrevented, true);
assert.equal(f.value, "before\n\nafter");
assert.deepEqual(readPathSelection(f.root), { anchor: start, focus: start });
});
test("browser target ranges that intersect a visual chip interior expand the complete source atomically", async () => {
const source = "keep old `/tmp/notes.pdf` tail";
const f = await fixture(source);
f.select(5);
const label = f.root.querySelector(`[${PATH_SOURCE_ATTRIBUTE}] [role=img] span`)!.firstChild!;
const start = pathInputPoint(f.root, 5);
const target = { startContainer: start.node, startOffset: start.offset, endContainer: label, endOffset: 2 };
const mapped = pathInputStaticRange(f.root, target)!;
assert.ok(mapped.focus > source.indexOf("`"));
assert.ok(mapped.focus < source.lastIndexOf("`") + 1);
await f.event("beforeinput", { inputType: "deleteWordForward", getTargetRanges: () => [target] });
assert.equal(f.value, "keep tail");
assert.equal(f.root.querySelector(`[${PATH_SOURCE_ATTRIBUTE}]`), null);
});
test("missing or invalid word/line target ranges defer to native editing and preserve owned undo", async () => {
const source = "Explain `/tmp/notes.pdf`";
const f = await fixture(source);
f.select(source.length);
for (const modifier of ["metaKey", "ctrlKey", "altKey"]) {
for (const key of ["Backspace", "Delete"]) {
const event = await f.event("keydown", { key, [modifier]: true });
assert.equal(event.defaultPrevented, false);
assert.equal(f.value, source);
}
}
for (const inputType of ["deleteWordBackward", "deleteWordForward", "deleteSoftLineBackward", "deleteSoftLineForward", "deleteHardLineBackward", "deleteHardLineForward"]) {
for (const getTargetRanges of [undefined, () => []]) {
const beforeInput = await f.event("beforeinput", { inputType, getTargetRanges });
assert.equal(beforeInput.defaultPrevented, false, `${inputType} must not fall through to adjacent-chip deletion`);
assert.equal(f.value, source);
}
}
const valid = { startContainer: f.root, startOffset: 0, endContainer: f.root, endOffset: f.root.childNodes.length };
for (const getTargetRanges of [
undefined, () => [], () => [{ ...valid, startContainer: f.doc.body }],
() => [{ ...valid, startOffset: -1 }], () => [{ ...valid, endOffset: 999 }],
() => [{ ...valid, endOffset: 0 }], () => [valid, valid], () => { throw new Error("not available"); },
]) {
const beforeInput = await f.event("beforeinput", { inputType: "deleteWordBackward", getTargetRanges });
assert.equal(beforeInput.defaultPrevented, false);
assert.equal(f.value, source);
}
// With no usable target ranges the browser edits the DOM, then input projects it.
await f.nativeText("", "deleteHardLineBackward");
assert.equal(f.value, "");
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, source);
});
test("ordinary backward/forward chip deletion and selected ranges retain their existing behavior", async () => {
const source = "a `/tmp/notes.pdf` b";
const f = await fixture(source);
f.select(source.length - 2);
const backspace = await f.event("keydown", { key: "Backspace" });
assert.equal(backspace.defaultPrevented, true);
assert.equal(f.value, "a b");
await f.external(source);
f.select(2);
const forward = await f.event("keydown", { key: "Delete" });
assert.equal(forward.defaultPrevented, true);
assert.equal(f.value, "a b");
await f.external(source);
f.select(source.length, 0);
const selected = await f.event("keydown", { key: "Backspace" });
assert.equal(selected.defaultPrevented, true);
assert.equal(f.value, "");
});
test("immutable SSR fallback never stomps native edits, controlled updates, chips or reset", async () => {
const initial = "Restored draft `/course/original.pdf`";
const f = await fixture(initial);
assert.equal(readPathInput(f.root), initial);
assert.equal(f.root.querySelectorAll(`[${PATH_SOURCE_ATTRIBUTE}]`).length, 1);
f.select(initial.length);
await f.nativeText("New typed draft");
assert.equal(readPathInput(f.root), "New typed draft");
assert.equal(f.root.textContent, "New typed draft");
await f.external("Controlled `/course/replaced.pdf`");
assert.equal(readPathInput(f.root), "Controlled `/course/replaced.pdf`");
assert.equal(f.root.querySelectorAll(`[${PATH_SOURCE_ATTRIBUTE}]`).length, 1);
assert.doesNotMatch(f.root.textContent!, /Restored draft/);
await f.external("");
await f.external(""); // Re-render again with the same initial fallback child prop.
assert.equal(readPathInput(f.root), "");
assert.equal(f.root.textContent, "");
assert.equal(f.root.dataset.empty, "true");
});
test("external dictation append/reset and drop at an unfocused saved selection", async () => {
const f = await fixture("one two");
f.select(3);
f.doc.getElementById("elsewhere")!.focus();
await f.event(INSERT_FILE_PATHS_EVENT, { detail: ["/notes one.pdf"] });
assert.equal(f.value, "one `/notes one.pdf` two");
assert.equal(f.doc.activeElement, f.root);
assert.equal(f.keys, 0, "staging must not submit");
f.select(f.value.length);
await f.external(`${f.value}\nspoken words`);
assert.equal(readPathInput(f.root), f.value);
await f.event("keydown", { key: "z", ctrlKey: true });
assert.equal(f.value, "one `/notes one.pdf` two");
await f.external("");
assert.equal(readPathInput(f.root), "");
await f.event("keydown", { key: "z", ctrlKey: true });
assert.equal(f.value, "");
});
test("plaintext paste ignores HTML and copy/cut use full token source in either direction", async () => {
const source = "read `/deep/course/notes.pdf` now";
const f = await fixture(source);
f.select(source.length - 4, 5);
const clipboard = new Map<string, string>();
const clipboardData = { setData: (type: string, text: string) => clipboard.set(type, text), getData: (type: string) => type === "text/plain" ? "<img src=x onerror=alert(1)>\n中" : "<b>unsafe HTML</b>" };
await f.event("copy", { clipboardData });
assert.equal(clipboard.get("text/plain"), "`/deep/course/notes.pdf`");
await f.event("cut", { clipboardData });
assert.equal(f.value, "read now");
f.select(5);
await f.event("paste", { clipboardData });
assert.equal(f.value, "read <img src=x onerror=alert(1)>\n中 now");
assert.equal(f.root.querySelector("img"), null);
assert.equal(readPathInput(f.root), f.value);
});
test("image/file-only paste prevents native insertion without deleting selected text or chips", async () => {
const source = "plain `/course/notes.pdf` end";
const f = await fixture(source);
const clipboardData = { types: ["Files", "image/png"], getData: () => "" };
for (const [anchor, focus] of [[0, 5], [6, source.length - 4]]) {
f.select(anchor!, focus!);
const paste = await f.event("paste", { clipboardData });
assert.equal(paste.defaultPrevented, true);
assert.equal(f.value, source);
assert.equal(readPathInput(f.root), source);
}
assert.deepEqual(f.writes, []);
assert.equal(f.root.querySelectorAll(`[${PATH_SOURCE_ATTRIBUTE}]`).length, 1);
});
test("empty clipboard text stays a no-op before, during and after IME with a chip selected", async () => {
const source = "a `/course/notes.pdf` z";
const f = await fixture(source);
f.select(2, source.length - 2);
const clipboardData = { getData: () => "", types: ["text/html"] };
await f.event("paste", { clipboardData });
await f.event("compositionstart");
const during = await f.event("paste", { clipboardData });
assert.equal(during.defaultPrevented, true);
assert.equal(readPathInput(f.root), source);
await f.event("compositionend");
assert.equal(readPathInput(f.root), source, "no empty pending paste can remove the selected chip");
await f.event("paste", { clipboardData });
assert.equal(f.value, source);
assert.deepEqual(f.writes, []);
});
test("Shift+Delete delegates to cut, preserving full-source clipboard and deleting only once", async () => {
const f = await fixture("plain`/next.pdf`");
for (const [source, selected] of [["plain`/next.pdf`", "plain"], ["`/course/notes.pdf` `/next.pdf`", "`/course/notes.pdf` "]] as const) {
await f.external(source);
f.select(0, selected.length);
const key = await f.event("keydown", { key: "Delete", shiftKey: true });
assert.equal(key.defaultPrevented, false, "native Cut shortcut must reach the browser");
assert.equal(f.value, source, "keydown alone must not delete the selection");
let copied = "";
const cut = await f.event("cut", { clipboardData: { setData: (type: string, value: string) => { assert.equal(type, "text/plain"); copied = value; } } });
assert.equal(cut.defaultPrevented, true);
assert.equal(copied, selected);
assert.equal(f.value, "`/next.pdf`");
const beforeInput = await f.event("beforeinput", { inputType: "deleteByCut" });
assert.equal(beforeInput.defaultPrevented, true);
assert.equal(f.value, "`/next.pdf`", "a trailing deleteByCut must not delete the neighboring chip");
}
});
test("copying even a partial visual chip label copies its complete source", async () => {
const f = await fixture("read `/deep/course/notes.pdf` now");
const label = f.root.querySelector(`[${PATH_SOURCE_ATTRIBUTE}] [role=img] span`)!.firstChild!;
f.selection.setBaseAndExtent(label, 1, label, 3);
f.doc.dispatchEvent(new window.Event("selectionchange"));
let copied = "";
await f.event("copy", { clipboardData: { setData: (_type: string, value: string) => { copied = value; } } });
assert.equal(copied, "`/deep/course/notes.pdf`");
});
test("composition keeps native DOM untouched, emits once, and suppresses consumer Enter", async () => {
const f = await fixture("hello ");
f.select(6);
await f.event("compositionstart");
f.root.replaceChildren(f.doc.createTextNode("hello 中"));
f.select(7);
await f.event("input", { isComposing: true });
await f.event("keydown", { key: "Enter", keyCode: 229, isComposing: true });
assert.equal(f.writes.length, 0);
assert.equal(f.keys, 0);
assert.equal(readPathInput(f.root), "hello 中");
await f.event("compositionend");
assert.deepEqual(f.writes, ["hello 中"]);
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "hello ");
});
test("Enter inserts a literal newline and range deletion spans text and complete chips", async () => {
const f = await fixture("a `/one.pdf` b");
f.select(f.value.length);
await f.event("beforeinput", { inputType: "insertParagraph" });
assert.equal(f.value, "a `/one.pdf` b\n");
f.select(1, f.value.length - 1);
await f.event("keydown", { key: "Delete" });
assert.equal(f.value, "a\n");
assert.equal(readPathInput(f.root), "a\n");
});
test("multiple trailing newlines preserve selection and survive native Backspace plus undo/redo", async () => {
const f = await fixture("one\n\n");
f.select(f.value.length, 1);
assert.deepEqual(readPathSelection(f.root), { anchor: 5, focus: 1 });
f.select(f.value.length);
assert.equal(f.selection.focusNode, f.root);
assert.equal(f.root.lastElementChild?.hasAttribute(PATH_CARET_ATTRIBUTE), true);
// linkedom has no default Backspace action; model the native text deletion.
await f.nativeText("one\n", "deleteContentBackward");
assert.equal(f.value, "one\n");
assert.equal(readPathInput(f.root), f.value);
assert.equal(f.root.querySelectorAll(`[${PATH_CARET_ATTRIBUTE}]`).length, 1);
await f.event("keydown", { key: "z", metaKey: true });
assert.equal(f.value, "one\n\n");
assert.deepEqual(readPathSelection(f.root), { anchor: 5, focus: 5 });
assert.equal(f.root.querySelectorAll(`[${PATH_CARET_ATTRIBUTE}]`).length, 1);
await f.event("keydown", { key: "z", metaKey: true, shiftKey: true });
assert.equal(f.value, "one\n");
assert.deepEqual(readPathSelection(f.root), { anchor: 4, focus: 4 });
await f.external("");
assert.equal(f.root.dataset.empty, "true");
assert.equal(f.root.querySelector(`[${PATH_CARET_ATTRIBUTE}]`), null);
});
test("single-line mode never adds a trailing newline caret placeholder", async () => {
const f = await fixture("one\n\n", true);
assert.equal(readPathInput(f.root), "one");
assert.equal(f.root.querySelector(`[${PATH_CARET_ATTRIBUTE}]`), null);
});
test("paste during IME stays plaintext and is deferred until composition finishes", async () => {
const f = await fixture("");
f.select(0);
await f.event("compositionstart");
f.root.replaceChildren(f.doc.createTextNode("中"));
f.select(1);
await f.event("input", { isComposing: true });
assert.equal(f.root.dataset.empty, "false", "placeholder hides without normalizing the composing DOM");
const paste = await f.event("paste", { clipboardData: { getData: () => "<b>plain</b>" } });
assert.equal(paste.defaultPrevented, true);
assert.equal(readPathInput(f.root), "中");
assert.equal(f.writes.length, 0);
await f.event("compositionend");
assert.equal(f.value, "中<b>plain</b>");
assert.equal(f.root.querySelector("b"), null);
});
test("single-line normalization strips only CR/LF and remaps directional UTF-16 selections", () => {
const source = "🙂\r\n中\t x\ny\r";
assert.equal(normalizePathInputValue(source, false), source);
assert.equal(normalizePathInputValue(source, true), "🙂中\t xy");
assert.deepEqual(normalizePathEdit({ value: source, selection: { anchor: source.length, focus: 4 } }, true), {
value: "🙂中\t xy", selection: { anchor: 7, focus: 2 },
});
});
test("single-line mode exposes native input accessibility and horizontal, nonwrapping layout", () => {
const html = renderToStaticMarkup(<PathInput value="" onValueChange={() => {}} singleLine className="h-8" />);
assert.match(html, /aria-multiline="false"/);
assert.match(html, /whitespace-pre /);
assert.match(html, /overflow-x-auto/);
assert.match(html, /overflow-y-hidden/);
assert.doesNotMatch(html, /whitespace-pre-wrap/);
assert.match(html, /h-8/);
});
test("single-line initial/external assignment sanitizes silently; native edits and paste emit sanitized values", async () => {
const f = await fixture("one\r\ntwo\n", true);
assert.equal(readPathInput(f.root), "onetwo");
assert.deepEqual(f.writes, []);
await f.external("hello\r\nworld");
assert.equal(readPathInput(f.root), "helloworld");
assert.deepEqual(f.writes, []);
f.select(10);
await f.nativeText("ab\r\nc");
assert.equal(f.value, "abc");
assert.equal(readPathInput(f.root), "abc");
assert.equal(pathInputOffset(f.root, f.selection.focusNode!, f.selection.focusOffset), 3);
await f.event("paste", { clipboardData: { getData: () => "x\r\n y\n" } });
assert.equal(f.value, "abcx y");
assert.deepEqual(f.writes, ["abc", "abcx y"]);
});
test("single-line Enter reaches its consumer but cannot insert a newline", async () => {
const f = await fixture("answer", true);
f.select(6);
await f.event("keydown", { key: "Enter", shiftKey: true });
assert.equal(f.keys, 1);
const paragraph = await f.event("beforeinput", { inputType: "insertParagraph" });
const lineBreak = await f.event("beforeinput", { inputType: "insertLineBreak" });
assert.equal(paragraph.defaultPrevented, true);
assert.equal(lineBreak.defaultPrevented, true);
assert.equal(f.value, "answer");
assert.deepEqual(f.writes, []);
await f.event(INSERT_FILE_PATHS_EVENT, { detail: ["/course/notes.pdf"] });
assert.equal(f.value, "answer `/course/notes.pdf`");
});
test("single-line IME sanitizes after completion, and queued external assignments do not synthesize changes", async () => {
const f = await fixture("a", true);
f.select(1);
await f.event("compositionstart");
f.root.replaceChildren(f.doc.createTextNode("a中\r\n文"));
f.select(5);
await f.event("input", { isComposing: true });
assert.deepEqual(f.writes, []);
await f.event("compositionend");
assert.equal(f.value, "a中文");
assert.equal(readPathInput(f.root), "a中文");
await f.event("compositionstart");
await f.external("external\r\nvalue");
assert.equal(readPathInput(f.root), "a中文", "assignment waits for native composition to finish");
await f.event("compositionend");
assert.equal(readPathInput(f.root), "externalvalue");
assert.deepEqual(f.writes, ["a中文"]);
});
+105 -1
View File
@@ -8,9 +8,10 @@
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { realHeadless } from "../src/agent/subagents/headless.ts"; import { MAX_PDF_CANVAS_PIXELS, MAX_PDF_CANVAS_SIDE, pdfCanvasGeometry, realHeadless } from "../src/agent/subagents/headless.ts";
import { standardFontsDir } from "../src/agent/subagents/pdf-ops.ts"; import { standardFontsDir } from "../src/agent/subagents/pdf-ops.ts";
const FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf"); const FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
@@ -31,6 +32,83 @@ function pngFacts(buf: Buffer): { w: number; h: number; idats: number } {
return { w, h, idats }; return { w, h, idats };
} }
/** Synthetic page only — no personal PDF or external fixture generator. */
function oversizedPdf(): Buffer {
const content = "0 0 1 rg 0 0 20000 10000 re f\n";
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 20000 10000] /Rotate 90 /Resources << >> /Contents 4 0 R >>",
`<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}endstream`,
];
let pdf = "%PDF-1.7\n";
const offsets = [0];
for (const [index, object] of objects.entries()) {
offsets.push(Buffer.byteLength(pdf));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
}
const xref = Buffer.byteLength(pdf);
pdf += `xref\n0 ${offsets.length}\n0000000000 65535 f \n`;
for (const offset of offsets.slice(1)) pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
pdf += `trailer\n<< /Size ${offsets.length} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
return Buffer.from(pdf);
}
describe("PDF pre-allocation canvas geometry", () => {
it("uses decimal 16 megapixels and an 8192-pixel side limit", () => {
assert.equal(MAX_PDF_CANVAS_PIXELS, 16_000_000);
assert.equal(MAX_PDF_CANVAS_SIDE, 8192);
});
it("preserves ordinary requested DPI, including fractional ceil dimensions and low DPI", () => {
for (const dpi of [11, 72, 144, 150, 300]) {
const geometry = pdfCanvasGeometry(595.28, 841.89, dpi);
assert.deepEqual(geometry, {
scale: dpi / 72,
width: Math.ceil(595.28 * dpi / 72),
height: Math.ceil(841.89 * dpi / 72),
});
}
});
it("bounds extreme aspect ratios, huge geometry, sides, and rounded pixel area without upscaling", () => {
const cases: readonly (readonly [number, number, number])[] = [
[1e12, 0.000001, 300],
[0.000001, 1e12, 300],
[1e308, 1e308, Number.MAX_VALUE],
[8192.000001, 10, 72],
[6100, 3900, 72], // ceil at the area-limited scale exceeds 16MP
[4000.000001, 4000.000001, 72],
[4000, 4000, 72], // exactly 16MP stays unchanged
];
for (const [width, height, dpi] of cases) {
const result = pdfCanvasGeometry(width, height, dpi);
assert.ok(result.scale > 0 && result.scale <= dpi / 72);
assert.equal(result.width, Math.max(1, Math.ceil(width * result.scale)));
assert.equal(result.height, Math.max(1, Math.ceil(height * result.scale)));
assert.ok(result.width <= MAX_PDF_CANVAS_SIDE && result.height <= MAX_PDF_CANVAS_SIDE);
assert.ok(result.width * result.height <= MAX_PDF_CANVAS_PIXELS);
}
assert.deepEqual(pdfCanvasGeometry(4000, 4000, 72), { scale: 1, width: 4000, height: 4000 });
});
it("uses rotated viewport dimensions symmetrically", () => {
const normal = pdfCanvasGeometry(20000, 10000, 144);
const rotated = pdfCanvasGeometry(10000, 20000, 144);
assert.equal(rotated.scale, normal.scale);
assert.equal(rotated.width, normal.height);
assert.equal(rotated.height, normal.width);
});
it("rejects nonfinite or nonpositive dimensions and DPI cleanly", () => {
for (const invalid of [0, -1, NaN, Infinity, -Infinity]) {
assert.throws(() => pdfCanvasGeometry(invalid, 792, 72), /dimensions must be finite and positive/);
assert.throws(() => pdfCanvasGeometry(612, invalid, 72), /dimensions must be finite and positive/);
assert.throws(() => pdfCanvasGeometry(612, 792, invalid), /DPI must be finite and positive/);
}
});
});
describe("native pdf_render (pdfjs paints, @napi-rs/canvas hosts)", () => { describe("native pdf_render (pdfjs paints, @napi-rs/canvas hosts)", () => {
it("the shared font directory ends in pdfjs's required slash on every OS", () => { it("the shared font directory ends in pdfjs's required slash on every OS", () => {
const fonts = standardFontsDir(); const fonts = standardFontsDir();
@@ -63,6 +141,32 @@ describe("native pdf_render (pdfjs paints, @napi-rs/canvas hosts)", () => {
assert.ok(Math.abs(fb.h - 2 * fa.h) <= 2, `144dpi height ≈ 2× 72dpi (${fa.h} → ${fb.h})`); assert.ok(Math.abs(fb.h - 2 * fa.h) <= 2, `144dpi height ≈ 2× 72dpi (${fa.h} → ${fb.h})`);
}); });
it("bounds a synthetic oversized rotated PDF before native canvas allocation", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pdf-canvas-test-"));
try {
const fixture = path.join(dir, "oversized.pdf");
fs.writeFileSync(fixture, oversizedPdf());
const [page] = await realHeadless.renderPdfPages(fixture, [1], { dpi: 144 });
assert.ok(page);
const facts = pngFacts(page.png);
const expected = pdfCanvasGeometry(10000, 20000, 144);
assert.equal(facts.w, expected.width);
assert.equal(facts.h, expected.height);
assert.ok(facts.h > facts.w, "PDF rotation is included before scaling");
assert.ok(facts.w <= 8192 && facts.h <= 8192);
assert.ok(facts.w * facts.h <= 16_000_000);
assert.equal(facts.idats, 1, "raw renderer still returns canonical PNG");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("rejects invalid DPI before opening the PDF", async () => {
for (const dpi of [0, -1, NaN, Infinity, -Infinity, Number.MIN_VALUE]) {
await assert.rejects(realHeadless.renderPdfPages("unused.pdf", [1], { dpi }), /DPI must be finite and positive/);
}
});
it("skips out-of-range page numbers silently", async () => { it("skips out-of-range page numbers silently", async () => {
const pages = await realHeadless.renderPdfPages(FIXTURE, [0, 2, 99], { dpi: 72 }); const pages = await realHeadless.renderPdfPages(FIXTURE, [0, 2, 99], { dpi: 72 });
assert.deepEqual( assert.deepEqual(
+3
View File
@@ -45,6 +45,9 @@ describe("standalone JavaScript tracing exclusions and native workaround policy"
"node_modules/pdfjs-dist/legacy/**/*", "node_modules/pdfjs-dist/legacy/**/*",
"node_modules/pdfjs-dist/standard_fonts/**/*", "node_modules/pdfjs-dist/standard_fonts/**/*",
"node_modules/@napi-rs/canvas-*/**/*", "node_modules/@napi-rs/canvas-*/**/*",
"src/agent/image-processing/*.mjs",
"src/agent/image-processing/LICENSE",
"node_modules/@silvia-odwyer/photon-node/**/*",
"node_modules/@resvg/resvg-js-*/**/*", "node_modules/@resvg/resvg-js-*/**/*",
"node_modules/@earendil-works/pi-ai/dist/providers/data/**/*", "node_modules/@earendil-works/pi-ai/dist/providers/data/**/*",
"node_modules/@earendil-works/pi-ai/dist/auth/oauth/{load,github-copilot,xai,device-code}.js", "node_modules/@earendil-works/pi-ai/dist/auth/oauth/{load,github-copilot,xai,device-code}.js",
+7 -3
View File
@@ -10,10 +10,12 @@ import assert from "node:assert/strict";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { describe, it } from "node:test"; import { after, describe, it } from "node:test";
import { createCanvas } from "@napi-rs/canvas";
import { readTool } from "../src/agent/tools/read.ts"; import { readTool } from "../src/agent/tools/read.ts";
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "lh-read-test-")); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "lh-read-test-"));
after(() => fs.rmSync(tmp, { recursive: true, force: true }));
function write(name: string, content: string): string { function write(name: string, content: string): string {
const file = path.join(tmp, name); const file = path.join(tmp, name);
@@ -100,8 +102,10 @@ describe("read tool — text semantics", () => {
describe("read tool — image semantics (native content parts)", () => { describe("read tool — image semantics (native content parts)", () => {
it("returns native text + image parts for image files, details null (output-less part)", async () => { it("returns native text + image parts for image files, details null (output-less part)", async () => {
const file = path.join(tmp, "dot.png"); const file = path.join(tmp, "dot.png");
// minimal valid PNG header bytes are enough — detection is by extension // Detection now sniffs bytes and the native processor decodes the image.
fs.writeFileSync(file, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); const canvas = createCanvas(1, 1);
canvas.getContext("2d").fillRect(0, 0, 1, 1);
fs.writeFileSync(file, canvas.toBuffer("image/png"));
const result = await readTool.execute!("test", { path: file }); const result = await readTool.execute!("test", { path: file });
assert.equal(result.content.length, 2); assert.equal(result.content.length, 2);
const parts = result.content as Array<{ type: string; text?: string; data?: string; mimeType?: string }>; const parts = result.content as Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
+1 -1
View File
@@ -96,7 +96,7 @@ describe("question submission draft restoration", () => {
it("restores an ask's custom answer and a free-text answer", () => { it("restores an ask's custom answer and a free-text answer", () => {
const custom = renderToStaticMarkup(<QuestionDock toolName="ask_user_question" toolCallId="ask1" input={{ question: "Fixture?", options: [{ label: "Listed answer" }, { label: "Another answer" }], multiSelect: true }} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />); const custom = renderToStaticMarkup(<QuestionDock toolName="ask_user_question" toolCallId="ask1" input={{ question: "Fixture?", options: [{ label: "Listed answer" }, { label: "Another answer" }], multiSelect: true }} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />);
assert.ok(custom.includes('value="preserved custom answer"')); assert.match(custom, /role="textbox"[^>]*aria-multiline="false"[^>]*>preserved custom answer<\/div>/);
assert.match(custom, /aria-checked="true" data-testid="other-option"/); assert.match(custom, /aria-checked="true" data-testid="other-option"/);
const text = renderToStaticMarkup(<QuestionDock toolName="ask_user_question" toolCallId="ask2" input={{ question: "Fixture?" }} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />); const text = renderToStaticMarkup(<QuestionDock toolName="ask_user_question" toolCallId="ask2" input={{ question: "Fixture?" }} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />);
assert.ok(text.includes("preserved free-text answer")); assert.ok(text.includes("preserved free-text answer"));
+1 -1
View File
@@ -100,7 +100,7 @@ describe("DraftShell — the sessionless surface", () => {
const html = renderToStaticMarkup(<DraftShell busy={false} text="keep all my text" onChange={() => {}} onSend={() => {}} startError />); const html = renderToStaticMarkup(<DraftShell busy={false} text="keep all my text" onChange={() => {}} onSend={() => {}} startError />);
assert.ok(html.includes('role="alert"')); assert.ok(html.includes('role="alert"'));
assert.ok(html.includes(OPENING_PROMPT_FAILURE)); assert.ok(html.includes(OPENING_PROMPT_FAILURE));
assert.match(html, /<textarea[^>]*>keep all my text<\/textarea>/); assert.match(html, /<div[^>]*role="textbox"[^>]*contentEditable="true"[^>]*>keep all my text<\/div>/);
}); });
it("renders the composer blob, the stats bar, and the fresh-chat empty state", () => { it("renders the composer blob, the stats bar, and the fresh-chat empty state", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
+2 -1
View File
@@ -81,13 +81,14 @@ describe("sessionFindRanges — the displayed-text DOM seam", () => {
assert.ok((ranges[0]!.startContainer as Text).data.includes("session")); assert.ok((ranges[0]!.startContainer as Text).data.includes("session"));
}); });
it("hidden subtrees never match: hidden attr, menus, katex MathML duplicate, textareas (display:none is browser-verified)", () => { it("hidden subtrees never match: hidden attr, menus, katex MathML duplicate, draft editors (display:none is browser-verified)", () => {
mount(`<div data-session-find-root=""> mount(`<div data-session-find-root="">
<p>needle visible</p> <p>needle visible</p>
<p hidden>needle hidden-attr</p> <p hidden>needle hidden-attr</p>
<div data-lh-menu><p>needle menu</p></div> <div data-lh-menu><p>needle menu</p></div>
<span class="katex-mathml">needle mathml</span> <span class="katex-mathml">needle mathml</span>
<textarea>needle draft</textarea> <textarea>needle draft</textarea>
<div contenteditable="true" data-file-path-input>needle path draft <span contenteditable="false">notes.pdf</span></div>
</div>`); </div>`);
const ranges = sessionFindRanges(document.body as unknown as HTMLElement, "needle"); const ranges = sessionFindRanges(document.body as unknown as HTMLElement, "needle");
assert.equal(ranges.length, 1); assert.equal(ranges.length, 1);