Merge accepted local main into PR #154 working branch
This commit is contained in:
@@ -13,6 +13,9 @@ dist/
|
|||||||
|
|
||||||
# dev auth/keys store (auth-keys wave): repo-root auth.json — secrets never committed
|
# dev auth/keys store (auth-keys wave): repo-root auth.json — secrets never committed
|
||||||
auth.json
|
auth.json
|
||||||
|
chatgpt-credentials.json*
|
||||||
|
github-copilot-credentials.json*
|
||||||
|
xai-credentials.json*
|
||||||
|
|
||||||
# dev-fresh launcher scratch (fresh-user sim): disposable per launch
|
# dev-fresh launcher scratch (fresh-user sim): disposable per launch
|
||||||
.fresh-data/
|
.fresh-data/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, sessionCost, stores, type ChatDeleteMode } from "@/src/server";
|
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, sessionCost, stores, turnFailure, type ChatDeleteMode } from "@/src/server";
|
||||||
|
|
||||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -19,6 +19,8 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
|||||||
messages: chat.messages,
|
messages: chat.messages,
|
||||||
pricing: sessionCost(chat),
|
pricing: sessionCost(chat),
|
||||||
turnInFlight: isTurnRunning(id),
|
turnInFlight: isTurnRunning(id),
|
||||||
|
// Also reaches idle views that missed the failing turn's stream.
|
||||||
|
turnError: turnFailure(id),
|
||||||
compacting: compactionInFlight(id),
|
compacting: compactionInFlight(id),
|
||||||
coverage: sessionCoverage(id),
|
coverage: sessionCoverage(id),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
* submitter too attaches here. One SSE stream per chat: a running turn →
|
* submitter too attaches here. One SSE stream per chat: a running turn →
|
||||||
* subscribe to the live turn object: FULL replay of the event log, then
|
* subscribe to the live turn object: FULL replay of the event log, then
|
||||||
* live chunks until settle ([DONE]). No running turn → the cheap terminal
|
* live chunks until settle ([DONE]). No running turn → the cheap terminal
|
||||||
* ({"type":"turn-settled"} + [DONE]): the record is current, the client
|
* ({"type":"turn-settled"} + [DONE]): the client refetches the record.
|
||||||
* refetches it.
|
* A retained settlement-failure notice precedes that terminal when a
|
||||||
|
* reply could not be saved before the submitter attached.
|
||||||
*
|
*
|
||||||
* 404 mirrors the POST handler's unknown-chat. Framework route is a thin
|
* 404 mirrors the POST handler's unknown-chat. Framework route is a thin
|
||||||
* wrapper over the server barrel (the import law); tests drive the seams
|
* wrapper over the server barrel (the import law); tests drive the seams
|
||||||
@@ -18,6 +19,6 @@ export async function GET(_req: Request, ctx: { params: Promise<{ id: string }>
|
|||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
if (!getChat(id)) return Response.json({ error: "Unknown chat" }, { status: 404 });
|
if (!getChat(id)) return Response.json({ error: "Unknown chat" }, { status: 404 });
|
||||||
const handle = runningTurn(id);
|
const handle = runningTurn(id);
|
||||||
if (!handle) return settledTurnResponse();
|
if (!handle) return settledTurnResponse(id);
|
||||||
return piTurnResponse(handle);
|
return piTurnResponse(handle);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
* (server/settings-body.ts): a bad shape/type is a 400 carrying the
|
* (server/settings-body.ts): a bad shape/type is a 400 carrying the
|
||||||
* supported list where one exists. The chat and models sections
|
* supported list where one exists. The chat and models sections
|
||||||
* additionally pass the catalog gate (validateChatSettings /
|
* additionally pass the catalog gate (validateChatSettings /
|
||||||
* validateModelsSettings — unknown provider/model = 400). Thinking
|
* validateModelsSettings — unknown provider/model = 400, except unchanged
|
||||||
|
* model-slot selections may remain saved while unavailable). Thinking
|
||||||
* levels never reject a write (owner, interlude 2026-09-22): a pinned
|
* levels never reject a write (owner, interlude 2026-09-22): a pinned
|
||||||
* level CLAMPS to the named model's nearest supported level and the
|
* level CLAMPS to the named model's nearest supported level and the
|
||||||
* clamped value is stored — model swaps just work, same as the
|
* clamped value is stored — model swaps just work, same as the
|
||||||
@@ -63,6 +64,8 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st
|
|||||||
if (!parsed.ok) {
|
if (!parsed.ok) {
|
||||||
return Response.json({ error: parsed.error }, { status: 400 });
|
return Response.json({ error: parsed.error }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
// Compare against the same record that this whole-section write replaces.
|
||||||
|
const record = readSettingsRecord() ?? { version: 1 };
|
||||||
let agentHealed: AgentSettings | undefined;
|
let agentHealed: AgentSettings | undefined;
|
||||||
|
|
||||||
// Section-specific cross-validation (against the catalog / the prompt
|
// Section-specific cross-validation (against the catalog / the prompt
|
||||||
@@ -73,11 +76,11 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st
|
|||||||
return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 });
|
return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Model slots wave (2026-09-20): the models section gets the same
|
// New/replaced slot models pass the catalog gate. Unchanged saved model
|
||||||
// catalog gate the chat section gets — a slot must name a real
|
// references may survive disconnect, including thinking-preference edits,
|
||||||
// provider+model and a thinking level it supports.
|
// so each unavailable slot can be repaired independently of the others.
|
||||||
if (section === "models") {
|
if (section === "models") {
|
||||||
const verdict = validateModelsSettings(parsed.settings as ModelsSettings);
|
const verdict = validateModelsSettings(parsed.settings as ModelsSettings, record.models);
|
||||||
if (!verdict.ok) {
|
if (!verdict.ok) {
|
||||||
return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 });
|
return Response.json({ error: verdict.error, supported: verdict.supported }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -124,7 +127,6 @@ export async function PUT(request: Request, ctx: { params: Promise<{ section: st
|
|||||||
|
|
||||||
// Read-modify-write ONE section of the record; everything else rides
|
// Read-modify-write ONE section of the record; everything else rides
|
||||||
// along untouched (other sections, unknown keys — sanitize preserves).
|
// along untouched (other sections, unknown keys — sanitize preserves).
|
||||||
const record = readSettingsRecord() ?? { version: 1 };
|
|
||||||
const next = { ...record, [section]: sectionOut };
|
const next = { ...record, [section]: sectionOut };
|
||||||
writeSettingsRecord(next);
|
writeSettingsRecord(next);
|
||||||
return Response.json({ settings: next });
|
return Response.json({ settings: next });
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { subscriptionBrowserResponse } from "@/src/server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const GET = subscriptionBrowserResponse;
|
||||||
|
export const POST = subscriptionBrowserResponse;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { subscriptionControlsResponse } from "@/src/server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const GET = subscriptionControlsResponse;
|
||||||
|
export const POST = subscriptionControlsResponse;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Chat } from "@/components/chat";
|
import { Chat } from "@/components/chat";
|
||||||
import { slotsConfigured } from "@/src/core";
|
import { slotsConfigured } from "@/src/core";
|
||||||
import { compactionInFlight, getChat, isTurnRunning, readSettingsRecord } from "@/src/server";
|
import { compactionInFlight, getChat, isTurnRunning, readSettingsRecord, turnFailure } from "@/src/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ export default async function ChatPage({
|
|||||||
// derived app-side (core/view.ts projectView; GET serves the same
|
// derived app-side (core/view.ts projectView; GET serves the same
|
||||||
// native messages).
|
// native messages).
|
||||||
initialMessages={chat.messages}
|
initialMessages={chat.messages}
|
||||||
|
initialError={turnFailure(chat.id)}
|
||||||
initialConfigured={slotsConfigured(record?.models)}
|
initialConfigured={slotsConfigured(record?.models)}
|
||||||
initialPrompt={prompt}
|
initialPrompt={prompt}
|
||||||
// A teacher turn that outlived its client: the remounted view must
|
// A teacher turn that outlived its client: the remounted view must
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
type PluginConfig,
|
type PluginConfig,
|
||||||
} from "streamdown";
|
} from "streamdown";
|
||||||
import { mdComponents } from "@/components/md-blocks";
|
import { mdComponents } from "@/components/md-blocks";
|
||||||
import { transformMathDelimiters } from "@/src/core";
|
import { repairMathForDisplay, transformMathDelimiters } from "@/src/core";
|
||||||
|
|
||||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
from: LhViewMessage["role"];
|
from: LhViewMessage["role"];
|
||||||
@@ -388,6 +388,16 @@ export const MessageResponse = memo(
|
|||||||
|
|
||||||
MessageResponse.displayName = "MessageResponse";
|
MessageResponse.displayName = "MessageResponse";
|
||||||
|
|
||||||
|
/** Recovery is a tool-text presentation concern, not a shape/answer-data
|
||||||
|
* transform. Ordinary assistant/user prose keeps MessageResponse unchanged. */
|
||||||
|
export function ToolContentResponse({ children, ...props }: MessageResponseProps) {
|
||||||
|
return (
|
||||||
|
<MessageResponse {...props}>
|
||||||
|
{typeof children === "string" ? repairMathForDisplay(children) : children}
|
||||||
|
</MessageResponse>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type MessageToolbarProps = ComponentProps<"div">;
|
export type MessageToolbarProps = ComponentProps<"div">;
|
||||||
|
|
||||||
export const MessageToolbar = ({
|
export const MessageToolbar = ({
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ 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 { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { MessageResponse } from "@/components/ai-elements/message";
|
import { ToolContentResponse } from "@/components/ai-elements/message";
|
||||||
import type { QuestionAnswerData } from "@/src/core";
|
import type { QuestionAnswerData } from "@/src/core";
|
||||||
import { parseAskInput, parseQuizInput } from "@/src/core";
|
import { answerForPresentation, parseAskInput, parseQuizInput } from "@/src/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Static rendering of an answered question (tool part in `output-available`).
|
* Static rendering of an answered question (tool part in `output-available`).
|
||||||
@@ -19,7 +19,8 @@ import { parseAskInput, parseQuizInput } from "@/src/core";
|
|||||||
* client — the card reveals his answer, the committed correct answer, and
|
* client — the card reveals his answer, the committed correct answer, and
|
||||||
* the explanation, with NO verdict chips (owner ruling 2026-09-27).
|
* the explanation, with NO verdict chips (owner ruling 2026-09-27).
|
||||||
*/
|
*/
|
||||||
export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; input?: unknown }) {
|
export function AnsweredCard({ answer: savedAnswer, input }: { answer: QuestionAnswerData; input?: unknown }) {
|
||||||
|
const answer = answerForPresentation(savedAnswer, input);
|
||||||
const [whyOpen, setWhyOpen] = useState(false); // the tucked explanation (predictions, owner 2026-10-03)
|
const [whyOpen, setWhyOpen] = useState(false); // the tucked explanation (predictions, owner 2026-10-03)
|
||||||
if (answer.dismissed) {
|
if (answer.dismissed) {
|
||||||
return (
|
return (
|
||||||
@@ -86,13 +87,13 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3 font-medium">
|
<div className="mb-3 font-medium">
|
||||||
<MessageResponse>{answer.questionText}</MessageResponse>
|
<ToolContentResponse>{answer.questionText}</ToolContentResponse>
|
||||||
</div>
|
</div>
|
||||||
{parsedDetails && (
|
{parsedDetails && (
|
||||||
// The details line, same paint as the pending dock (owner,
|
// The details line, same paint as the pending dock (owner,
|
||||||
// 2026-10-12): full markdown, LaTeX included.
|
// 2026-10-12): full markdown, LaTeX included.
|
||||||
<div className="mb-3 text-sm text-muted-foreground">
|
<div className="mb-3 text-sm text-muted-foreground">
|
||||||
<MessageResponse>{parsedDetails}</MessageResponse>
|
<ToolContentResponse>{parsedDetails}</ToolContentResponse>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -131,14 +132,14 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className={cn(!sel && "text-muted-foreground")}>
|
<span className={cn(!sel && "text-muted-foreground")}>
|
||||||
<MessageResponse>{opt.label}</MessageResponse>
|
<ToolContentResponse>{opt.label}</ToolContentResponse>
|
||||||
{descByIndex.get(opt.index) && (
|
{descByIndex.get(opt.index) && (
|
||||||
// Ask-option subtext (owner, 2026-10-12): same markdown path
|
// Ask-option subtext (owner, 2026-10-12): same markdown path
|
||||||
// as the pending dock — LaTeX included. Quiz options carry
|
// as the pending dock — LaTeX included. Quiz options carry
|
||||||
// none (owner ruling 2026-09-26); the map stays empty for them.
|
// none (owner ruling 2026-09-26); the map stays empty for them.
|
||||||
<MessageResponse className="lh-prose block text-xs text-muted-foreground">
|
<ToolContentResponse className="lh-prose block text-xs text-muted-foreground">
|
||||||
{descByIndex.get(opt.index)}
|
{descByIndex.get(opt.index)}
|
||||||
</MessageResponse>
|
</ToolContentResponse>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,9 +212,9 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
|
|||||||
{whyOpen && (
|
{whyOpen && (
|
||||||
<div className="mt-1.5 rounded-lg bg-muted py-2 text-sm text-foreground/50">
|
<div className="mt-1.5 rounded-lg bg-muted py-2 text-sm text-foreground/50">
|
||||||
<span className="font-medium text-muted-foreground">Why: </span>
|
<span className="font-medium text-muted-foreground">Why: </span>
|
||||||
{/* The markdown wrapper must go truly inline — a block <p> after the
|
{/* Keep the first paragraph beside the label, not every paragraph:
|
||||||
"Why:" label created the oversized leading gap (owner's fix list). */}
|
later paragraphs must retain their recovered document structure. */}
|
||||||
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
|
<ToolContentResponse className="inline [&>p:first-child]:inline [&>p:first-child]:m-0">{answer.explanation}</ToolContentResponse>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -225,7 +226,7 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
|
|||||||
{isExplain && answer.committed && (
|
{isExplain && answer.committed && (
|
||||||
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
|
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
|
||||||
<span className="font-medium text-foreground">The answer: </span>
|
<span className="font-medium text-foreground">The answer: </span>
|
||||||
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.committed}</MessageResponse>
|
<ToolContentResponse className="inline [&>p:first-child]:inline [&>p:first-child]:m-0">{answer.committed}</ToolContentResponse>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { BrailleSpinner } from "@/components/braille-spinner";
|
|||||||
*
|
*
|
||||||
* job unknown → "dispatched" pulse (the jobs feed hasn't seen it yet)
|
* job unknown → "dispatched" pulse (the jobs feed hasn't seen it yet)
|
||||||
* running → live activity card (activity line + elapsed)
|
* running → live activity card (activity line + elapsed)
|
||||||
* done/error → quiet one-liner ("delivered" / "failed") — the artifact
|
* done/error → quiet one-liner ("finished" / "failed") — the artifact
|
||||||
* itself arrives in the steer message right below, so the
|
* itself arrives in the steer message right below, so the
|
||||||
* box must not duplicate it.
|
* box must not duplicate it.
|
||||||
*/
|
*/
|
||||||
@@ -54,7 +54,7 @@ export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: J
|
|||||||
>
|
>
|
||||||
{failed ? <XIcon className="size-3.5 text-red-500" /> : <CheckIcon className="size-3.5" />}
|
{failed ? <XIcon className="size-3.5 text-red-500" /> : <CheckIcon className="size-3.5" />}
|
||||||
<span className="font-medium uppercase tracking-wide">{label}</span>
|
<span className="font-medium uppercase tracking-wide">{label}</span>
|
||||||
<span>{failed ? "failed" : "delivered"}</span>
|
<span>{failed ? "failed" : "finished"}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/** Two display-only repairs for inputs the old HTML embedding accepted.
|
||||||
|
* Do not rewrite stored artifacts/downloads or attempt general XML repair.
|
||||||
|
* Comments, CDATA and processing instructions are copied verbatim. */
|
||||||
|
export function svgImageSource(svg: string): string {
|
||||||
|
let foundRoot = false;
|
||||||
|
return svg.replace(
|
||||||
|
/<!--[\s\S]*?(?:-->|$)|<!\[CDATA\[[\s\S]*?(?:\]\]>|$)|<\?[\s\S]*?(?:\?>|$)|<\/?[a-zA-Z](?:[^<>"']|"[^"]*"|'[^']*')*>| /g,
|
||||||
|
(token) => {
|
||||||
|
if (token.startsWith("<!--") || token.startsWith("<![CDATA[") || token.startsWith("<?")) return token;
|
||||||
|
if (!foundRoot && /^<svg(?=[\s/>])/i.test(token)) {
|
||||||
|
foundRoot = true;
|
||||||
|
// Consume complete attribute values, so text mentioning xmlns
|
||||||
|
// inside a different attribute cannot masquerade as a declaration.
|
||||||
|
const attrs = token.matchAll(/\s([^\s=/>]+)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/g);
|
||||||
|
const hasNamespace = Array.from(attrs).some((attr) => attr[1] === "xmlns");
|
||||||
|
if (!hasNamespace) token = token.replace(/^<svg/i, '$& xmlns="http://www.w3.org/2000/svg"');
|
||||||
|
}
|
||||||
|
return token.replace(/ /g, " ");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep generated SVG in an image document, outside the app's CSS and ID scope.
|
||||||
|
* This is a display boundary, not a sanitizer for the original SVG download. */
|
||||||
|
export function SvgImage({
|
||||||
|
svg,
|
||||||
|
className,
|
||||||
|
width,
|
||||||
|
}: {
|
||||||
|
svg: string;
|
||||||
|
className?: string;
|
||||||
|
width?: number;
|
||||||
|
}) {
|
||||||
|
// The source is already an in-memory vector image; no Next image optimization.
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
return <img src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgImageSource(svg))}`} alt="Figure" className={className} width={width} draggable={false} />;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { Toggle } from "@/components/settings/controls";
|
|||||||
import { useSessionMermaid } from "@/components/session-overlay";
|
import { useSessionMermaid } from "@/components/session-overlay";
|
||||||
import { downloadFile } from "@/components/artifacts/download";
|
import { downloadFile } from "@/components/artifacts/download";
|
||||||
import { DispatchCard } from "@/components/artifacts/dispatch-card";
|
import { DispatchCard } from "@/components/artifacts/dispatch-card";
|
||||||
|
import { SvgImage } from "@/components/artifacts/svg-image";
|
||||||
import type { ArtifactPartProps, VisualResult } from "@/src/core";
|
import type { ArtifactPartProps, VisualResult } from "@/src/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,22 +17,10 @@ import type { ArtifactPartProps, VisualResult } from "@/src/core";
|
|||||||
* the chat view's jobs feed). The SVG itself renders from the steer message
|
* the chat view's jobs feed). The SVG itself renders from the steer message
|
||||||
* (sub-agent result delivery) via VisualOutputCard.
|
* (sub-agent result delivery) via VisualOutputCard.
|
||||||
*
|
*
|
||||||
* The SVG is model-authored for the learner's own machine (their own teacher
|
* Both display modes use an SVG image document: generated markup and its
|
||||||
* wrote it, verified by looking) — dangerouslySetInnerHTML is acceptable
|
* styles never join the app's DOM. Downloads still contain the original SVG.
|
||||||
* here, with a cheap hygiene pass (sanitizeSvg: strip <script> blocks, on*
|
|
||||||
* handlers, and javascript:/data:text/html URLs) since it lands in the
|
|
||||||
* app's DOM.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Cheap SVG hygiene — not a full sanitizer, just removing the obvious exec vectors. */
|
|
||||||
export function sanitizeSvg(svg: string): string {
|
|
||||||
return svg
|
|
||||||
.replace(/<script[\s\S]*?<\/script\s*>/gi, "")
|
|
||||||
.replace(/<script[^>]*\/?>/gi, "")
|
|
||||||
.replace(/\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
|
|
||||||
.replace(/(href|xlink:href)\s*=\s*(?:"\s*(?:javascript|data:text\/html)[^"]*"|'\s*(?:javascript|data:text\/html)[^']*'|(?:javascript|data:text\/html)[^\s>]*)/gi, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The svg root's DECLARED width in px, when there is one — the natural
|
/** The svg root's DECLARED width in px, when there is one — the natural
|
||||||
* size the card displays at instead of stretching to full width (owner,
|
* size the card displays at instead of stretching to full width (owner,
|
||||||
* 2026-10-07). %, em, "auto", or a missing attribute → null (the caller
|
* 2026-10-07). %, em, "auto", or a missing attribute → null (the caller
|
||||||
@@ -122,13 +111,18 @@ export function VisualOutputCard({
|
|||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
natural !== null
|
natural !== null
|
||||||
? "w-fit max-w-full overflow-hidden rounded-lg bg-white [&_svg]:h-auto [&_svg]:max-w-full"
|
? "w-fit max-w-full overflow-hidden rounded-lg bg-white"
|
||||||
: "w-full overflow-hidden rounded-lg bg-white [&_svg]:h-auto [&_svg]:w-full"
|
: "w-full overflow-hidden rounded-lg bg-white"
|
||||||
}
|
}
|
||||||
style={{ filter: inverted ? "invert(1) hue-rotate(180deg)" : undefined }}
|
style={{ filter: inverted ? "invert(1) hue-rotate(180deg)" : undefined }}
|
||||||
dangerouslySetInnerHTML={{ __html: sanitizeSvg(result.svg) }}
|
|
||||||
data-testid="visual-svg"
|
data-testid="visual-svg"
|
||||||
/>
|
>
|
||||||
|
<SvgImage
|
||||||
|
svg={result.svg}
|
||||||
|
width={natural ?? undefined}
|
||||||
|
className={natural !== null ? "block h-auto max-w-full" : "block h-auto w-full"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="mt-2 flex items-center justify-between gap-2">
|
<div className="mt-2 flex items-center justify-between gap-2">
|
||||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Figure</div>
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Figure</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|||||||
+103
-113
@@ -5,7 +5,7 @@ import { useChatSession } from "@/components/session";
|
|||||||
import { visibleQueuedSteers } from "@/components/session-core";
|
import { visibleQueuedSteers } from "@/components/session-core";
|
||||||
import { SetupDialog } from "@/components/setup-dialog";
|
import { SetupDialog } from "@/components/setup-dialog";
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
|
import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta";
|
||||||
import { useStickToBottomContext, type StickToBottomContext } from "use-stick-to-bottom";
|
import { useStickToBottomContext, type StickToBottomContext } from "use-stick-to-bottom";
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
@@ -27,6 +27,7 @@ import { useEnterSubmits } from "@/components/enter-submits";
|
|||||||
import { blurAfterPointerClick } from "@/components/focus-hygiene";
|
import { blurAfterPointerClick } from "@/components/focus-hygiene";
|
||||||
import { dockStateKey } from "@/components/dock-state-key";
|
import { dockStateKey } from "@/components/dock-state-key";
|
||||||
import { clearOpeningPrompt, readOpeningPrompt } from "@/components/opening-prompt";
|
import { clearOpeningPrompt, readOpeningPrompt } from "@/components/opening-prompt";
|
||||||
|
import { forwardDockWheel, useDockHeight } from "@/components/dock-height";
|
||||||
import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers";
|
import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers";
|
||||||
import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation";
|
import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation";
|
||||||
import { useFieldCycle } from "@/components/field-cycle";
|
import { useFieldCycle } from "@/components/field-cycle";
|
||||||
@@ -39,7 +40,6 @@ import {
|
|||||||
messageAnchorId,
|
messageAnchorId,
|
||||||
textPartMessageId,
|
textPartMessageId,
|
||||||
kbdEventForAction as kbdEventForChat,
|
kbdEventForAction as kbdEventForChat,
|
||||||
needsContinuation,
|
|
||||||
type ChatInput,
|
type ChatInput,
|
||||||
type ChatSettings,
|
type ChatSettings,
|
||||||
type LhMessage,
|
type LhMessage,
|
||||||
@@ -58,7 +58,7 @@ import { PracticeDock } from "@/components/practice-dock";
|
|||||||
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
|
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
|
||||||
import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card";
|
import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card";
|
||||||
import { TopBar } from "@/components/top-bar";
|
import { TopBar } from "@/components/top-bar";
|
||||||
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage, SessionPricing } from "@/src/core";
|
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, QuestionFormDraft, TurnUsage, SessionPricing } from "@/src/core";
|
||||||
import type { LhViewMessage } from "@/src/core";
|
import type { LhViewMessage } from "@/src/core";
|
||||||
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
||||||
import {
|
import {
|
||||||
@@ -79,8 +79,6 @@ import {
|
|||||||
} from "@/src/core";
|
} from "@/src/core";
|
||||||
import { ReadingThinkingField } from "@/components/reading-thinking";
|
import { ReadingThinkingField } from "@/components/reading-thinking";
|
||||||
import { currentUserThinkingOpen, setAllUserThinkingOpen } from "@/components/thinking-block";
|
import { currentUserThinkingOpen, setAllUserThinkingOpen } from "@/components/thinking-block";
|
||||||
import { slotsConfigured, type ModelsSettings } from "@/src/core";
|
|
||||||
import { useSettingsSaved } from "@/components/settings-signal";
|
|
||||||
|
|
||||||
/** One width for everything: transcript, cards, and the bottom blob share this column. */
|
/** One width for everything: transcript, cards, and the bottom blob share this column. */
|
||||||
/**
|
/**
|
||||||
@@ -199,6 +197,7 @@ export function Chat({
|
|||||||
chatId,
|
chatId,
|
||||||
title,
|
title,
|
||||||
initialMessages,
|
initialMessages,
|
||||||
|
initialError,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
initiallyInFlight,
|
initiallyInFlight,
|
||||||
initiallyCompacting = false,
|
initiallyCompacting = false,
|
||||||
@@ -208,6 +207,8 @@ export function Chat({
|
|||||||
chatId: string;
|
chatId: string;
|
||||||
title: string;
|
title: string;
|
||||||
initialMessages: LhMessage[];
|
initialMessages: LhMessage[];
|
||||||
|
/** Memory-only settlement notice supplied on reopening. */
|
||||||
|
initialError?: string;
|
||||||
/** The session kind (practice sessions, owner 2026-10-04): practice
|
/** The session kind (practice sessions, owner 2026-10-04): practice
|
||||||
* renders no phase chip (no probe→teach arc applies) and a Practice
|
* renders no phase chip (no probe→teach arc applies) and a Practice
|
||||||
* tag rides the header instead. */
|
* tag rides the header instead. */
|
||||||
@@ -237,8 +238,8 @@ export function Chat({
|
|||||||
// (core/view.ts projectView); sends are ONE explicit input event each
|
// (core/view.ts projectView); sends are ONE explicit input event each
|
||||||
// ({chatId, input} — the stash indirection died with the SDK), and the
|
// ({chatId, input} — the stash indirection died with the SDK), and the
|
||||||
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
|
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
|
||||||
const { messages, nativeMessages, contextTokens, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
const { messages, nativeMessages, contextTokens, send, continueIfOwed, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, questionDraft, saveQuestionDraft, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
||||||
useChatSession({ chatId, initialMessages, initiallyInFlight });
|
useChatSession({ chatId, initialMessages, initialError, initiallyInFlight });
|
||||||
|
|
||||||
const sessionFindScopeRef = useRef<HTMLDivElement | null>(null);
|
const sessionFindScopeRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scrollContextRef = useRef<StickToBottomContext | null>(null);
|
const scrollContextRef = useRef<StickToBottomContext | null>(null);
|
||||||
@@ -447,9 +448,7 @@ export function Chat({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mountContinuation.current) return;
|
if (mountContinuation.current) return;
|
||||||
mountContinuation.current = true;
|
mountContinuation.current = true;
|
||||||
if (!turnInFlight && status === "ready" && needsContinuation(initialMessages)) {
|
if (!turnInFlight) continueIfOwed(initialMessages);
|
||||||
void send({ type: "continue" });
|
|
||||||
}
|
|
||||||
// mount-only by design (turnInFlight read at initial value)
|
// mount-only by design (turnInFlight read at initial value)
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -545,10 +544,12 @@ export function Chat({
|
|||||||
}, []);
|
}, []);
|
||||||
const pendingQuestionId = pending?.toolCallId ?? null;
|
const pendingQuestionId = pending?.toolCallId ?? null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// A new pending question never inherits the previous one's draft.
|
// Different calls never share a draft; a failed submission of THIS
|
||||||
readingThinkingRef.current = "";
|
// call restores the reading channel alongside the form's snapshot.
|
||||||
setReadingThinking("");
|
const reading = pendingQuestionId ? questionDraft(pendingQuestionId)?.readingThinking ?? "" : "";
|
||||||
}, [pendingQuestionId]);
|
readingThinkingRef.current = reading;
|
||||||
|
setReadingThinking(reading);
|
||||||
|
}, [pendingQuestionId, questionDraft]);
|
||||||
|
|
||||||
// The phase at every message (one linear fold): the transcript draws a
|
// The phase at every message (one linear fold): the transcript draws a
|
||||||
// color-coded dotted divider at each boundary; the header chip reads the
|
// color-coded dotted divider at each boundary; the header chip reads the
|
||||||
@@ -565,30 +566,18 @@ export function Chat({
|
|||||||
textRef.current = v;
|
textRef.current = v;
|
||||||
setText(v);
|
setText(v);
|
||||||
}, []);
|
}, []);
|
||||||
// The BYOK setup flow (owner, 2026-09-22): the persistent CTA and the
|
// Saved slot completeness is live, but the session's selected route
|
||||||
// refusal interception share one SetupDialog mount. `configured` is
|
// comes from its own ordinary model control, never today's Strong pin.
|
||||||
// seeded server-side (slotsConfigured on the record) and flips on Save.
|
const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured);
|
||||||
const [configured, setConfigured] = useState(initialConfigured);
|
const catalogRef = useRef(catalog);
|
||||||
// The zero-keys truth (zero-keys wave): no provider key = the pill
|
catalogRef.current = catalog;
|
||||||
// takes precedence over the models CTA (keys come first in the chain).
|
const [selectedModel, setSelectedModel] = useState<string | undefined>();
|
||||||
// Reads once on mount, re-reads on the "keys" signal.
|
|
||||||
const zeroKeys = useZeroKeys();
|
|
||||||
// Cross-surface freshness (fix wave): another surface's models write —
|
|
||||||
// setup save, composer pick, a settings write — re-reads the record so
|
|
||||||
// the setup CTA appears/disappears live (it used to lag until reload).
|
|
||||||
useSettingsSaved((section) => {
|
|
||||||
if (section !== "models") return;
|
|
||||||
fetch("/api/settings")
|
|
||||||
.then((r) => r.json() as Promise<{ settings?: { models?: ModelsSettings } | null } | null>)
|
|
||||||
.then((body) => setConfigured(slotsConfigured(body?.settings?.models)))
|
|
||||||
.catch(() => {});
|
|
||||||
});
|
|
||||||
const [setupOpen, setSetupOpen] = useState(false);
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
/** The last explicitly-sent draft (composer send): the
|
/** The last explicitly-sent draft (composer send): the
|
||||||
* refusal flow restores it into the composer, where it waits for the
|
* refusal flow restores it into the composer, where it waits for the
|
||||||
* learner to re-send (Save never auto-sends, owner 2026-10-09).
|
* learner to re-send (Save never auto-sends, owner 2026-10-09).
|
||||||
* Auto-continuations (probe answers) never set it — their refusal
|
* Auto-continuations (probe answers) never set it — their refusal
|
||||||
* opens the dialog with no replay. */
|
* opens recovery with no replay. */
|
||||||
const draftRef = useRef<string | null>(null);
|
const draftRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// THE BYOK REFUSAL (owner, 2026-09-22): a send that lands the server's
|
// THE BYOK REFUSAL (owner, 2026-09-22): a send that lands the server's
|
||||||
@@ -604,8 +593,9 @@ export function Chat({
|
|||||||
if (!error) return;
|
if (!error) return;
|
||||||
const missing = parseModelsRefusal(error.message);
|
const missing = parseModelsRefusal(error.message);
|
||||||
// The key-refusal twin (zero-keys wave): a pinned model whose
|
// The key-refusal twin (zero-keys wave): a pinned model whose
|
||||||
// provider key is gone gets the SAME treatment — revert-draft and
|
// provider key is gone also restores the draft. With another provider
|
||||||
// open setup (the dialog derives to its first-key stage on its own).
|
// connected, repair belongs to the ordinary current-session picker;
|
||||||
|
// otherwise setup derives its exclusive provider/loading stage.
|
||||||
const keyRefused = parseProviderKeyRefusal(error.message);
|
const keyRefused = parseProviderKeyRefusal(error.message);
|
||||||
if (!missing && !keyRefused) return; // a real error keeps the honest banner
|
if (!missing && !keyRefused) return; // a real error keeps the honest banner
|
||||||
// ORDER MATTERS: clearError() flips this effect's own dep (`error`)
|
// ORDER MATTERS: clearError() flips this effect's own dep (`error`)
|
||||||
@@ -625,7 +615,8 @@ export function Chat({
|
|||||||
draftRef.current = null;
|
draftRef.current = null;
|
||||||
}
|
}
|
||||||
clearError();
|
clearError();
|
||||||
setSetupOpen(true);
|
if (keyRefused && catalogRef.current?.providers.some((p) => p.keyPresent)) openModelPicker();
|
||||||
|
else setSetupOpen(true);
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -730,20 +721,20 @@ export function Chat({
|
|||||||
const syncFromServer = useCallback(async () => {
|
const syncFromServer = useCallback(async () => {
|
||||||
const res = await fetch(`/api/chats/${chatId}`);
|
const res = await fetch(`/api/chats/${chatId}`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[]; pricing?: SessionPricing };
|
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[]; pricing?: SessionPricing; turnError?: string };
|
||||||
if (Array.isArray(record.turns)) setTurns(record.turns);
|
if (Array.isArray(record.turns)) setTurns(record.turns);
|
||||||
setPricing(record.pricing);
|
setPricing(record.pricing);
|
||||||
if (!Array.isArray(record.messages)) return;
|
if (!Array.isArray(record.messages)) return;
|
||||||
setMessages(record.messages);
|
setMessages(record.messages, record.turnError);
|
||||||
// The continuation seam (contracts.ts protocol §4): a job result
|
// The continuation seam (contracts.ts protocol §4): a job result
|
||||||
// landing in the record leaves the agent owing a continuation — the
|
// landing in the record leaves the agent owing a continuation — the
|
||||||
// record ENDS on un-consumed results, the dock owns no question, and
|
// record ENDS on un-consumed results, the dock owns no question, and
|
||||||
// nothing runs. The native fold (needsContinuation) is the old SDK
|
// nothing runs. The native fold (needsContinuation) is the old SDK
|
||||||
// lastAssistantMessageIsCompleteWithToolCalls check.
|
// lastAssistantMessageIsCompleteWithToolCalls check.
|
||||||
if (status === "ready" && needsContinuation(record.messages)) {
|
// Current model truth also guards retained/live failures: canonical
|
||||||
void send({ type: "continue" });
|
// sync must never automatically replace a reply that failed to save.
|
||||||
}
|
continueIfOwed(record.messages);
|
||||||
}, [chatId, setMessages, send, status]);
|
}, [chatId, setMessages, continueIfOwed]);
|
||||||
|
|
||||||
// The settled terminal's other half (stream-reattach wave): the
|
// The settled terminal's other half (stream-reattach wave): the
|
||||||
// transport resolved turn-settled — no turn running, the record is
|
// transport resolved turn-settled — no turn running, the record is
|
||||||
@@ -918,8 +909,8 @@ export function Chat({
|
|||||||
// (endTurn emits from onFinish before the response terminates, so the
|
// (endTurn emits from onFinish before the response terminates, so the
|
||||||
// SSE frame reliably beats [DONE]) — the handler below cannot sync
|
// SSE frame reliably beats [DONE]) — the handler below cannot sync
|
||||||
// there, it flags instead, and THIS flip consumes the flag: the record
|
// there, it flags instead, and THIS flip consumes the flag: the record
|
||||||
// is guaranteed durable (endTurn fires only after persistTurn, the S2
|
// reflects durable truth (a failed settlement retains its notice,
|
||||||
// ordering law), so replacing the view onto it absorbs the tail's
|
// not its unsaved reply), so replacing the view absorbs the tail's
|
||||||
// streamed c_-id copies into base under their server ids. Without
|
// streamed c_-id copies into base under their server ids. Without
|
||||||
// this the tail kept those copies forever and the next send's
|
// this the tail kept those copies forever and the next send's
|
||||||
// steer-adoption window rendered them a second time. The wake handoff
|
// steer-adoption window rendered them a second time. The wake handoff
|
||||||
@@ -1156,6 +1147,14 @@ export function Chat({
|
|||||||
if (busy || pending || compacting) return;
|
if (busy || pending || compacting) return;
|
||||||
const t = textRef.current.trim();
|
const t = textRef.current.trim();
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
|
if (zeroKeys) {
|
||||||
|
setSetupOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedModelUnavailable(catalog, selectedModel)) {
|
||||||
|
openModelPicker();
|
||||||
|
return; // No optimistic send and no change to the learner's draft.
|
||||||
|
}
|
||||||
draftRef.current = t; // the refusal flow's preserve-and-replay seam
|
draftRef.current = t; // the refusal flow's preserve-and-replay seam
|
||||||
applyText("");
|
applyText("");
|
||||||
void send({ type: "text", text: t });
|
void send({ type: "text", text: t });
|
||||||
@@ -1609,10 +1608,13 @@ export function Chat({
|
|||||||
fold) and NEVER overlaps the composer. */}
|
fold) and NEVER overlaps the composer. */}
|
||||||
<DockOverlay
|
<DockOverlay
|
||||||
pending={pending}
|
pending={pending}
|
||||||
|
initialQuestionDraft={pending ? questionDraft(pending.toolCallId)?.form : undefined}
|
||||||
|
onSaveQuestionDraft={(form) => { if (pending) saveQuestionDraft(pending.toolCallId, { form }); }}
|
||||||
practice={activeProblem}
|
practice={activeProblem}
|
||||||
compactionRunning={compacting}
|
compactionRunning={compacting}
|
||||||
onSubmitThinking={submitThinking}
|
onSubmitThinking={submitThinking}
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
|
onModelChange={setSelectedModel}
|
||||||
plan={hasPlan ? <PlanMarginalia messages={messages} /> : undefined}
|
plan={hasPlan ? <PlanMarginalia messages={messages} /> : undefined}
|
||||||
onOpenPlan={() => setPlanOpen(true)}
|
onOpenPlan={() => setPlanOpen(true)}
|
||||||
requiresReveal={requiresReveal}
|
requiresReveal={requiresReveal}
|
||||||
@@ -1676,8 +1678,10 @@ export function Chat({
|
|||||||
// ride the same callback (QuestionAnswerData.dismissed). The
|
// ride the same callback (QuestionAnswerData.dismissed). The
|
||||||
// reading-thinking draft rides along (owner 2026-10-03) — the
|
// reading-thinking draft rides along (owner 2026-10-03) — the
|
||||||
// check answer is its only carrier.
|
// check answer is its only carrier.
|
||||||
|
saveQuestionDraft(pending.toolCallId, { readingThinking: readingThinkingRef.current });
|
||||||
const reading = readingThinkingRef.current.trim() || undefined;
|
const reading = readingThinkingRef.current.trim() || undefined;
|
||||||
clearReadingThinking();
|
// The pending-id effect clears this only when the question
|
||||||
|
// actually leaves. An offline preflight keeps the draft visible.
|
||||||
submitAnswer(pending.tool, pending.toolCallId, reading ? { ...output, readingThinking: reading } : output);
|
submitAnswer(pending.tool, pending.toolCallId, reading ? { ...output, readingThinking: reading } : output);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1714,7 +1718,6 @@ export function Chat({
|
|||||||
onClose={() => setSetupOpen(false)}
|
onClose={() => setSetupOpen(false)}
|
||||||
onDone={() => {
|
onDone={() => {
|
||||||
setSetupOpen(false);
|
setSetupOpen(false);
|
||||||
setConfigured(true);
|
|
||||||
// Save only saves (owner ruling 2026-10-09): the draft went
|
// Save only saves (owner ruling 2026-10-09): the draft went
|
||||||
// home into the composer at refusal time and STAYS there —
|
// home into the composer at refusal time and STAYS there —
|
||||||
// the learner sends it themselves.
|
// the learner sends it themselves.
|
||||||
@@ -1835,6 +1838,8 @@ export function shouldStampGeometryMute(nextH: number, prevH: number, pending: b
|
|||||||
|
|
||||||
function DockOverlay({
|
function DockOverlay({
|
||||||
pending,
|
pending,
|
||||||
|
initialQuestionDraft,
|
||||||
|
onSaveQuestionDraft,
|
||||||
practice,
|
practice,
|
||||||
compactionRunning,
|
compactionRunning,
|
||||||
onSubmitThinking,
|
onSubmitThinking,
|
||||||
@@ -1848,6 +1853,7 @@ function DockOverlay({
|
|||||||
composer,
|
composer,
|
||||||
onSubmitAnswer,
|
onSubmitAnswer,
|
||||||
chatId,
|
chatId,
|
||||||
|
onModelChange,
|
||||||
onReserve,
|
onReserve,
|
||||||
plan,
|
plan,
|
||||||
onOpenPlan,
|
onOpenPlan,
|
||||||
@@ -1857,6 +1863,8 @@ function DockOverlay({
|
|||||||
queuedSteers,
|
queuedSteers,
|
||||||
}: {
|
}: {
|
||||||
pending: ReturnType<typeof findPendingToolCall>;
|
pending: ReturnType<typeof findPendingToolCall>;
|
||||||
|
initialQuestionDraft?: QuestionFormDraft;
|
||||||
|
onSaveQuestionDraft: (draft: QuestionFormDraft) => void;
|
||||||
/** Compaction running for this chat (round 14) — the docks' submit
|
/** Compaction running for this chat (round 14) — the docks' submit
|
||||||
* affordance gate (the server's registry is the real one). */
|
* affordance gate (the server's registry is the real one). */
|
||||||
compactionRunning: boolean;
|
compactionRunning: boolean;
|
||||||
@@ -1882,6 +1890,7 @@ function DockOverlay({
|
|||||||
onSubmitAnswer: (output: QuestionAnswerData) => void;
|
onSubmitAnswer: (output: QuestionAnswerData) => void;
|
||||||
/** The model control's pass-through (the stats bar hosts it). */
|
/** The model control's pass-through (the stats bar hosts it). */
|
||||||
chatId: string;
|
chatId: string;
|
||||||
|
onModelChange: (model: string | undefined) => void;
|
||||||
/** Reports the dock's settled height (block + gradient) — ChatView's
|
/** Reports the dock's settled height (block + gradient) — ChatView's
|
||||||
* dockReserve state feeds the transcript's spacer from it. */
|
* dockReserve state feeds the transcript's spacer from it. */
|
||||||
onReserve: (h: number) => void;
|
onReserve: (h: number) => void;
|
||||||
@@ -1976,6 +1985,17 @@ function DockOverlay({
|
|||||||
const revealedRef = useRef(revealed);
|
const revealedRef = useRef(revealed);
|
||||||
revealedRef.current = revealed;
|
revealedRef.current = revealed;
|
||||||
|
|
||||||
|
const blockRef = useRef<HTMLDivElement>(null);
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const bodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
const shellRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mode = pending
|
||||||
|
? `question:${pending.toolCallId}`
|
||||||
|
: practice
|
||||||
|
? `practice:${practice.toolCallId}`
|
||||||
|
: "chat";
|
||||||
|
|
||||||
// The plan marginalia's flank width (owner, plan-placement round): from
|
// The plan marginalia's flank width (owner, plan-placement round): from
|
||||||
// the DOCK container's right edge to the block's right edge — the bare
|
// the DOCK container's right edge to the block's right edge — the bare
|
||||||
// diagram's canvas. Tracked LIVE by a ResizeObserver on the block
|
// diagram's canvas. Tracked LIVE by a ResizeObserver on the block
|
||||||
@@ -2014,28 +2034,33 @@ function DockOverlay({
|
|||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// The reserve grows immediately, but never shrinks while a question or
|
||||||
|
// practice problem owns the dock: shrinking flow space could pin a nearby
|
||||||
|
// learner back to the bottom. Composer mode may give space back.
|
||||||
|
const reserveRef = useRef(0);
|
||||||
|
const offer = useCallback((blockH: number) => {
|
||||||
|
const next = blockH + GRADIENT_H;
|
||||||
|
if (next <= reserveRef.current + 0.5 && pendingRef.current) return;
|
||||||
|
// Stamp growth at the source, including child-only field resizes, so
|
||||||
|
// stick-to-bottom's catch-up is not mistaken for the learner leaving.
|
||||||
|
if (shouldStampGeometryMute(next, reserveRef.current, pendingRef.current)) geometryAtRef.current = Date.now();
|
||||||
|
reserveRef.current = next;
|
||||||
|
onReserve(next);
|
||||||
|
}, [onReserve]);
|
||||||
|
|
||||||
// The overlay must not paint over the scroller's scrollbar (owner, scroll
|
// The overlay must not paint over the scroller's scrollbar (owner, scroll
|
||||||
// round 2): the scroller reserves symmetric gutters (scrollbar-gutter:
|
// round 2): the scroller reserves symmetric gutters (scrollbar-gutter:
|
||||||
// stable both-edges), so inset the overlay's right edge by the gutter —
|
// stable both-edges), so inset the overlay's right edge by the gutter —
|
||||||
// zero on macOS overlay scrollbars, the real width on classic platforms.
|
// zero on macOS overlay scrollbars, the real width on classic platforms.
|
||||||
// The same measure owns the QUESTION/PROBLEM HEIGHT CAP (owner round,
|
// Budget the whole solid dock, leaving only its question area flexible.
|
||||||
// 2026-10-05): the old fixed fractions (55dvh / 40vh) cut long problems
|
// The hook measures natural, visible content before the morph/reserve
|
||||||
// off absurdly short — the cap is now the AVAILABLE COLUMN (the
|
// effect below. Short windows scroll the reading/question/answer body;
|
||||||
// scroller already starts below the top bar) minus a 2rem breathing
|
// the stats/model toolbar remains outside that overflow viewport.
|
||||||
// gap, carried as --dock-max-h for the docks' scroll areas.
|
|
||||||
const [sbInset, setSbInset] = useState(0);
|
const [sbInset, setSbInset] = useState(0);
|
||||||
const [dockMaxH, setDockMaxH] = useState<number | null>(null);
|
useDockHeight({
|
||||||
useEffect(() => {
|
scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef,
|
||||||
const el = scrollRef.current;
|
expanded, enabled: !!pending || !!practice, mode, onScrollbarInset: setSbInset, onHeight: offer,
|
||||||
if (!el) return;
|
});
|
||||||
const measure = () => {
|
|
||||||
setSbInset(Math.round((el.offsetWidth - el.clientWidth) / 2));
|
|
||||||
setDockMaxH(Math.max(el.clientHeight - 32, 160));
|
|
||||||
};
|
|
||||||
measure();
|
|
||||||
window.addEventListener("resize", measure);
|
|
||||||
return () => window.removeEventListener("resize", measure);
|
|
||||||
}, [scrollRef]);
|
|
||||||
|
|
||||||
// GROWTH MUTE (owner round, 2026-10-05 — the generation churn): during a
|
// GROWTH MUTE (owner round, 2026-10-05 — the generation churn): during a
|
||||||
// live turn the transcript content grows and stick-to-bottom chases it;
|
// live turn the transcript content grows and stick-to-bottom chases it;
|
||||||
@@ -2071,48 +2096,11 @@ function DockOverlay({
|
|||||||
const root = blockRef.current;
|
const root = blockRef.current;
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!root || !el) return;
|
if (!root || !el) return;
|
||||||
const canScroll = (n: HTMLElement, dir: number) => {
|
const onWheel = (e: WheelEvent) => forwardDockWheel(e, root, el);
|
||||||
if (n.scrollHeight - n.clientHeight < 2) return false; // not scrollable
|
|
||||||
return dir < 0 ? n.scrollTop > 0 : n.scrollTop < n.scrollHeight - n.clientHeight - 0.5;
|
|
||||||
};
|
|
||||||
const onWheel = (e: WheelEvent) => {
|
|
||||||
if (e.deltaY === 0) return;
|
|
||||||
for (let n = e.target as HTMLElement | null; n && n !== root; n = n.parentElement) {
|
|
||||||
if (n instanceof HTMLElement && canScroll(n, e.deltaY)) return; // native
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
el.scrollTop += e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1);
|
|
||||||
};
|
|
||||||
root.addEventListener("wheel", onWheel, { passive: false });
|
root.addEventListener("wheel", onWheel, { passive: false });
|
||||||
return () => root.removeEventListener("wheel", onWheel);
|
return () => root.removeEventListener("wheel", onWheel);
|
||||||
}, [scrollRef]);
|
}, [scrollRef]);
|
||||||
|
|
||||||
// ── The reserve: the spacer's height, reported upward ──
|
|
||||||
// Grows immediately (typing, new variants). NEVER drops while a question
|
|
||||||
// is open — a shrinking reserved tail is scroll geometry and the browser
|
|
||||||
// clamp would pin a nearby learner to the bottom (the old oscillation).
|
|
||||||
// In chat mode drops apply (the composer's typing shrink is today's
|
|
||||||
// harmless in-flow behavior; nothing auto-expands there).
|
|
||||||
const reserveRef = useRef(0);
|
|
||||||
const blockRef = useRef<HTMLDivElement>(null);
|
|
||||||
const offer = (blockH: number) => {
|
|
||||||
const next = blockH + GRADIENT_H;
|
|
||||||
if (next <= reserveRef.current + 0.5 && pendingRef.current) return; // fold immunity
|
|
||||||
// Reserve GROWTH is the phantom-scroll SOURCE (flicker round,
|
|
||||||
// 2026-09-26): the spacer's height lands in this commit, the smooth
|
|
||||||
// catch-up lags it by the growth amount, and the position machine
|
|
||||||
// used to read that lag as the learner leaving — form → bar → form
|
|
||||||
// (the dictation flush, reproduced d≈152 > COLLAPSE_DIST). Stamp the
|
|
||||||
// mute right where the spacer grows: it covers the scroll events of
|
|
||||||
// THIS growth no matter how long after the field stretched (the
|
|
||||||
// field grows on its own render; the offer lands on the NEXT
|
|
||||||
// DockOverlay render — ~1s apart live). Shrinks never stamp (the
|
|
||||||
// fold's own give-back must not self-mute the re-expand).
|
|
||||||
if (shouldStampGeometryMute(next, reserveRef.current, pendingRef.current)) geometryAtRef.current = Date.now();
|
|
||||||
reserveRef.current = next;
|
|
||||||
onReserve(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Explicit expand (bar click): the smooth scroll to the bottom takes
|
// Explicit expand (bar click): the smooth scroll to the bottom takes
|
||||||
// hundreds of ms, during which the position is still legitimately inside
|
// hundreds of ms, during which the position is still legitimately inside
|
||||||
// the collapse zone — the machine would instantly re-collapse the dock
|
// the collapse zone — the machine would instantly re-collapse the dock
|
||||||
@@ -2214,11 +2202,6 @@ function DockOverlay({
|
|||||||
if (b) offer(b.getBoundingClientRect().height);
|
if (b) offer(b.getBoundingClientRect().height);
|
||||||
};
|
};
|
||||||
|
|
||||||
const mode = pending
|
|
||||||
? `question:${pending.toolCallId}`
|
|
||||||
: practice
|
|
||||||
? `practice:${practice.toolCallId}`
|
|
||||||
: "chat";
|
|
||||||
// The opening variant of a session appears instantly — the fade/rise is
|
// The opening variant of a session appears instantly — the fade/rise is
|
||||||
// reserved for mid-session transitions.
|
// reserved for mid-session transitions.
|
||||||
const initialModeRef = useRef(mode);
|
const initialModeRef = useRef(mode);
|
||||||
@@ -2226,11 +2209,9 @@ function DockOverlay({
|
|||||||
// Height morph: animate the shell from its previous height to its new one
|
// Height morph: animate the shell from its previous height to its new one
|
||||||
// whenever the variant (chat ↔ question ↔ collapsed bar) changes. Typing
|
// whenever the variant (chat ↔ question ↔ collapsed bar) changes. Typing
|
||||||
// growth keeps the same stateKey and is NOT animated.
|
// growth keeps the same stateKey and is NOT animated.
|
||||||
const shellRef = useRef<HTMLDivElement>(null);
|
|
||||||
// The fold's CONTENT fade (owner round, 2026-10-05): the shell height
|
// The fold's CONTENT fade (owner round, 2026-10-05): the shell height
|
||||||
// morphs, but the bar↔form content used to hard-swap. A WAAPI fade/rise
|
// morphs, but the bar↔form content used to hard-swap. A WAAPI fade/rise
|
||||||
// on the toggle — no remount, so the question form's state survives.
|
// on the toggle — no remount, so the question form's state survives.
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
|
||||||
const prevExpandedForFadeRef = useRef(expanded);
|
const prevExpandedForFadeRef = useRef(expanded);
|
||||||
useIsoLayoutEffect(() => {
|
useIsoLayoutEffect(() => {
|
||||||
if (prevExpandedForFadeRef.current === expanded) return;
|
if (prevExpandedForFadeRef.current === expanded) return;
|
||||||
@@ -2278,8 +2259,10 @@ function DockOverlay({
|
|||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0" style={{ right: sbInset }}>
|
<div className="pointer-events-none absolute inset-x-0 bottom-0" style={{ right: sbInset }}>
|
||||||
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
|
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
|
||||||
<div ref={blockRef} className="relative bg-background pb-5" style={{ "--dock-max-h": dockMaxH ? `${dockMaxH}px` : undefined } as React.CSSProperties}>
|
<div ref={blockRef} className="relative bg-background pb-5">
|
||||||
<div className={DOCK}>
|
<div className={DOCK}>
|
||||||
|
<div ref={viewportRef} data-dock-viewport="" className="pointer-events-auto">
|
||||||
|
<div ref={bodyRef}>
|
||||||
{setupCta}
|
{setupCta}
|
||||||
{/* The queued-steer indicator (owner spec 2026-10-03): quiet
|
{/* The queued-steer indicator (owner spec 2026-10-03): quiet
|
||||||
one-liners above the shell - right-aligned to where its corner
|
one-liners above the shell - right-aligned to where its corner
|
||||||
@@ -2308,11 +2291,13 @@ function DockOverlay({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
|
<div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
|
||||||
|
{/* Include descendant margins (notably practice thinking's
|
||||||
|
bottom margin) in the natural content measurement. */}
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
key={mode}
|
key={mode}
|
||||||
className={mode === initialModeRef.current ? undefined : "dock-variant"}
|
className={mode === initialModeRef.current ? "flow-root" : "flow-root dock-variant"}
|
||||||
>
|
>
|
||||||
{pending ? (
|
{pending ? (
|
||||||
<QuestionDock
|
<QuestionDock
|
||||||
toolName={pending.tool as "quiz" | "probe" | "ask_user_question" | "explain" | "recall"}
|
toolName={pending.tool as "quiz" | "probe" | "ask_user_question" | "explain" | "recall"}
|
||||||
@@ -2323,6 +2308,8 @@ function DockOverlay({
|
|||||||
phase={phase}
|
phase={phase}
|
||||||
onExpand={expandAndReveal}
|
onExpand={expandAndReveal}
|
||||||
onSubmit={onSubmitAnswer}
|
onSubmit={onSubmitAnswer}
|
||||||
|
initialDraft={initialQuestionDraft}
|
||||||
|
onSaveDraft={onSaveQuestionDraft}
|
||||||
onEscape={dictationCancel}
|
onEscape={dictationCancel}
|
||||||
dictationClaim={!revealPending}
|
dictationClaim={!revealPending}
|
||||||
compactionRunning={compactionRunning}
|
compactionRunning={compactionRunning}
|
||||||
@@ -2345,6 +2332,8 @@ function DockOverlay({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/* The stats row: beneath the composer/question shell, not
|
{/* The stats row: beneath the composer/question shell, not
|
||||||
flush to the screen bottom — static height, outside the
|
flush to the screen bottom — static height, outside the
|
||||||
morphing shell so it never joins a height animation. */}
|
morphing shell so it never joins a height animation. */}
|
||||||
@@ -2354,6 +2343,7 @@ function DockOverlay({
|
|||||||
pricing={pricing}
|
pricing={pricing}
|
||||||
dictationSlot={dictationSlot}
|
dictationSlot={dictationSlot}
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
|
onModelChange={onModelChange}
|
||||||
/>
|
/>
|
||||||
{/* The scroll-down disc (moved here from the Conversation,
|
{/* The scroll-down disc (moved here from the Conversation,
|
||||||
owner refinement round 2026-09-24): anchored to the BLOCK's
|
owner refinement round 2026-09-24): anchored to the BLOCK's
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useLayoutEffect, type RefObject } from "react";
|
||||||
|
|
||||||
|
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||||
|
|
||||||
|
export interface DockHeightMeasurements {
|
||||||
|
columnHeight: number;
|
||||||
|
/** Stats/toolbar and block padding, outside the scrolling viewport. */
|
||||||
|
outsideHeight: number;
|
||||||
|
/** Everything in the viewport except the animated shell. */
|
||||||
|
beforeShellHeight: number;
|
||||||
|
shellBorderHeight: number;
|
||||||
|
/** Natural content height, not the shell's animated/clamped height. */
|
||||||
|
contentHeight: number;
|
||||||
|
/** Null when collapsed, in chat mode, or showing malformed input. */
|
||||||
|
questionHeight: number | null;
|
||||||
|
questionPaddingHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dockHeightBudget(m: DockHeightMeasurements) {
|
||||||
|
const available = Math.max(m.columnHeight - 32, 0);
|
||||||
|
const viewportMaxHeight = Math.max(available - m.outsideHeight, 0);
|
||||||
|
const shellMaxHeight = Math.max(viewportMaxHeight - m.beforeShellHeight, 0);
|
||||||
|
const naturalHeight = m.beforeShellHeight + m.shellBorderHeight + m.contentHeight;
|
||||||
|
const questionMaxHeight = m.questionHeight === null
|
||||||
|
? null
|
||||||
|
: Math.max(viewportMaxHeight - (naturalHeight - m.questionHeight), 0);
|
||||||
|
// A cap smaller than the question's padding leaves no content viewport.
|
||||||
|
// Deliberately uncap the question and scroll the whole content instead;
|
||||||
|
// do not confuse a measured zero with an unmeasured CSS fallback.
|
||||||
|
const scrollWholeContent = questionMaxHeight === null
|
||||||
|
? naturalHeight > viewportMaxHeight
|
||||||
|
: questionMaxHeight <= m.questionPaddingHeight;
|
||||||
|
return { viewportMaxHeight, shellMaxHeight, questionMaxHeight, scrollWholeContent };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DockHeightElements {
|
||||||
|
column: HTMLElement;
|
||||||
|
block: HTMLElement;
|
||||||
|
viewport: HTMLElement;
|
||||||
|
body: HTMLElement;
|
||||||
|
shell: HTMLElement;
|
||||||
|
content: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pixels = (value: string) => Number.parseFloat(value) || 0;
|
||||||
|
|
||||||
|
/** Read natural geometry before writing any constraints. Cancel the shell's
|
||||||
|
* animated height out of both ancestor measurements, then substitute its
|
||||||
|
* natural content and borders. This stays valid during a WAAPI height morph
|
||||||
|
* and when the outer viewport is already capped. */
|
||||||
|
export function syncDockHeight(elements: DockHeightElements, expanded: boolean) {
|
||||||
|
const { column, block, viewport, body, shell, content } = elements;
|
||||||
|
// QuestionDock keeps a laid-out form in an invisible h-0 wrapper;
|
||||||
|
// PracticeDock removes its form. Neither hidden form is a height input.
|
||||||
|
const question = expanded ? content.querySelector<HTMLElement>("[data-dock-scroll-area]") : null;
|
||||||
|
const height = (el: HTMLElement) => el.getBoundingClientRect().height;
|
||||||
|
const shellStyle = getComputedStyle(shell);
|
||||||
|
const questionStyle = question ? getComputedStyle(question) : null;
|
||||||
|
const budget = dockHeightBudget({
|
||||||
|
columnHeight: column.clientHeight,
|
||||||
|
outsideHeight: Math.max(height(block) - height(viewport), 0),
|
||||||
|
beforeShellHeight: Math.max(height(body) - height(shell), 0),
|
||||||
|
shellBorderHeight: pixels(shellStyle.borderTopWidth) + pixels(shellStyle.borderBottomWidth),
|
||||||
|
contentHeight: height(content),
|
||||||
|
questionHeight: question ? height(question) : null,
|
||||||
|
questionPaddingHeight: questionStyle
|
||||||
|
? pixels(questionStyle.paddingTop) + pixels(questionStyle.paddingBottom)
|
||||||
|
: 0,
|
||||||
|
});
|
||||||
|
if (budget.questionMaxHeight === null) block.style.removeProperty("--dock-max-h");
|
||||||
|
else block.style.setProperty("--dock-max-h", budget.scrollWholeContent ? "none" : `${budget.questionMaxHeight}px`);
|
||||||
|
viewport.style.maxHeight = `${budget.viewportMaxHeight}px`;
|
||||||
|
// Overflow is visible in normal layouts: keep the shell's shadow intact.
|
||||||
|
// The stats/model toolbar is a sibling, never inside this overflow box.
|
||||||
|
viewport.style.overflowY = budget.scrollWholeContent ? "auto" : "visible";
|
||||||
|
// Clamp animated starting heights too, so a previous tall variant cannot
|
||||||
|
// paint above the column while morphing into a constrained layout.
|
||||||
|
shell.style.maxHeight = budget.scrollWholeContent ? "none" : `${budget.shellMaxHeight}px`;
|
||||||
|
return budget;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DockHeightRefs {
|
||||||
|
scrollRef: RefObject<HTMLElement | null>;
|
||||||
|
blockRef: RefObject<HTMLDivElement | null>;
|
||||||
|
viewportRef: RefObject<HTMLDivElement | null>;
|
||||||
|
bodyRef: RefObject<HTMLDivElement | null>;
|
||||||
|
shellRef: RefObject<HTMLDivElement | null>;
|
||||||
|
contentRef: RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDockHeight({
|
||||||
|
scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef,
|
||||||
|
expanded, enabled, mode, onScrollbarInset, onHeight,
|
||||||
|
}: DockHeightRefs & {
|
||||||
|
expanded: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
/** Reconnect observers when the keyed content node/scroll area changes. */
|
||||||
|
mode: string;
|
||||||
|
onScrollbarInset: (inset: number) => void;
|
||||||
|
/** Settled visible block height, fed through the existing reserve policy. */
|
||||||
|
onHeight: (height: number) => void;
|
||||||
|
}) {
|
||||||
|
const measure = useCallback(() => {
|
||||||
|
const column = scrollRef.current;
|
||||||
|
const block = blockRef.current;
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
const body = bodyRef.current;
|
||||||
|
const shell = shellRef.current;
|
||||||
|
const content = contentRef.current;
|
||||||
|
if (!column || !block || !viewport || !body || !shell || !content) return;
|
||||||
|
onScrollbarInset(Math.round((column.offsetWidth - column.clientWidth) / 2));
|
||||||
|
if (enabled) syncDockHeight({ column, block, viewport, body, shell, content }, expanded);
|
||||||
|
else {
|
||||||
|
block.style.removeProperty("--dock-max-h");
|
||||||
|
viewport.style.removeProperty("max-height");
|
||||||
|
viewport.style.removeProperty("overflow-y");
|
||||||
|
shell.style.removeProperty("max-height");
|
||||||
|
}
|
||||||
|
// ResizeObserver runs even when only a child field changed and React
|
||||||
|
// did not rerender DockOverlay. Keep its transcript reserve current,
|
||||||
|
// but never report a mid-morph height.
|
||||||
|
if (shell.getAnimations().length === 0) onHeight(block.getBoundingClientRect().height);
|
||||||
|
}, [scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef, expanded, enabled, onScrollbarInset, onHeight]);
|
||||||
|
|
||||||
|
// Imperative constraints land before the overlay's later layout effect
|
||||||
|
// measures its morph target/reserve, and before paint (including reveal).
|
||||||
|
useIsoLayoutEffect(measure);
|
||||||
|
useIsoLayoutEffect(() => {
|
||||||
|
const observer = new ResizeObserver(measure);
|
||||||
|
for (const el of [scrollRef.current, blockRef.current, viewportRef.current, bodyRef.current, contentRef.current]) {
|
||||||
|
if (el) observer.observe(el);
|
||||||
|
}
|
||||||
|
const question = expanded ? contentRef.current?.querySelector("[data-dock-scroll-area]") : null;
|
||||||
|
if (question) observer.observe(question);
|
||||||
|
window.addEventListener("resize", measure);
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
window.removeEventListener("resize", measure);
|
||||||
|
};
|
||||||
|
}, [measure, mode, expanded, scrollRef, blockRef, viewportRef, bodyRef, contentRef]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overlay and transcript are siblings. Keep native scrolling in an actual
|
||||||
|
* scroll container (including the short-window viewport), then forward only
|
||||||
|
* at its edge. overflow:hidden/visible boxes cannot consume the wheel. */
|
||||||
|
export function forwardDockWheel(event: WheelEvent, root: HTMLElement, transcript: HTMLElement) {
|
||||||
|
if (event.deltaY === 0) return;
|
||||||
|
for (let n = event.target as HTMLElement | null; n && n !== root; n = n.parentElement) {
|
||||||
|
if (!(n instanceof HTMLElement) || !/^(auto|scroll)$/.test(getComputedStyle(n).overflowY)) continue;
|
||||||
|
if (n.scrollHeight - n.clientHeight < 2) continue;
|
||||||
|
if (event.deltaY < 0 ? n.scrollTop > 0 : n.scrollTop < n.scrollHeight - n.clientHeight - 0.5) return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
transcript.scrollTop += event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? transcript.clientHeight : 1);
|
||||||
|
}
|
||||||
+12
-15
@@ -7,7 +7,7 @@ import { GraphView } from "@/components/graph-view";
|
|||||||
import { MinimalHome } from "@/components/minimal-home";
|
import { MinimalHome } from "@/components/minimal-home";
|
||||||
import { SetupDialog } from "@/components/setup-dialog";
|
import { SetupDialog } from "@/components/setup-dialog";
|
||||||
import { ComposerActions, NodePickerControl } from "@/components/composer-actions";
|
import { ComposerActions, NodePickerControl } from "@/components/composer-actions";
|
||||||
import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
|
import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta";
|
||||||
import { DictationBar, joinDictation, useDictation } from "@/components/dictation";
|
import { DictationBar, joinDictation, useDictation } from "@/components/dictation";
|
||||||
import { useFieldCycle } from "@/components/field-cycle";
|
import { useFieldCycle } from "@/components/field-cycle";
|
||||||
import { SidebarToggle } from "@/components/sidebar";
|
import { SidebarToggle } from "@/components/sidebar";
|
||||||
@@ -42,15 +42,8 @@ export function Home({
|
|||||||
// start until the user has picked all three slots — no defaults for
|
// start until the user has picked all three slots — no defaults for
|
||||||
// 33 providers. The dock shows the CTA while unconfigured; submitting
|
// 33 providers. The dock shows the CTA while unconfigured; submitting
|
||||||
// a topic opens the setup dialog WITH THE DRAFT PRESERVED (Save
|
// a topic opens the setup dialog WITH THE DRAFT PRESERVED (Save
|
||||||
// stores the slots and the queued message sends).
|
// stores slots only; the learner sends the message themselves).
|
||||||
const [configured, setConfigured] = useState(initialConfigured);
|
const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured);
|
||||||
// The zero-keys truth (zero-keys wave): no provider key = the pill
|
|
||||||
// takes precedence over the models CTA (keys come first in the chain).
|
|
||||||
const zeroKeys = useZeroKeys();
|
|
||||||
// The strong slot's keyless truth (pre-send key guards, owner interlude
|
|
||||||
// 2026-09-25): keys may exist generally while the STRONG slot's pinned
|
|
||||||
// provider has none — the same skip-everything treatment on send.
|
|
||||||
const strongKeyless = useStrongKeyless();
|
|
||||||
const [setupOpen, setSetupOpen] = useState(false);
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the
|
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the
|
||||||
// topic draft; the strip lives in the stats bar's middle slot.
|
// topic draft; the strip lives in the stats bar's middle slot.
|
||||||
@@ -124,13 +117,18 @@ export function Home({
|
|||||||
// models case guards BEFORE creation; the key cases must too — a
|
// models case guards BEFORE creation; the key cases must too — a
|
||||||
// refused first send would otherwise leave an orphan error-shell
|
// refused first send would otherwise leave an orphan error-shell
|
||||||
// chat in the sidebar (the 503 lands only after POST /api/chats
|
// chat in the sidebar (the 503 lands only after POST /api/chats
|
||||||
// already created the record). Same skip-everything-and-open-setup
|
// already created the record). Zero providers opens connection setup;
|
||||||
// treatment; the server's 503 gate remains the belt for /chat/[id]
|
// an unavailable selection with another provider opens its ordinary
|
||||||
// sessions.
|
// picker. The server's admission gate remains the belt.
|
||||||
if (zeroKeys || strongKeyless) {
|
if (zeroKeys) {
|
||||||
setSetupOpen(true);
|
setSetupOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const strong = catalog?.slots.strong?.model;
|
||||||
|
if (selectedModelUnavailable(catalog, strong ? `${strong.provider}:${strong.id}` : undefined)) {
|
||||||
|
openModelPicker();
|
||||||
|
return; // Preserve the draft and never create an orphan session.
|
||||||
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
// The map context (owner, 2026-10-04): picks in EITHER mode ride
|
// The map context (owner, 2026-10-04): picks in EITHER mode ride
|
||||||
@@ -330,7 +328,6 @@ export function Home({
|
|||||||
open={setupOpen}
|
open={setupOpen}
|
||||||
onClose={() => setSetupOpen(false)}
|
onClose={() => setSetupOpen(false)}
|
||||||
onDone={() => {
|
onDone={() => {
|
||||||
setConfigured(true);
|
|
||||||
setSetupOpen(false);
|
setSetupOpen(false);
|
||||||
// Save only saves: the draft waits in the composer.
|
// Save only saves: the draft waits in the composer.
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { ModelCatalogResponse } from "@/src/core";
|
||||||
|
import { onSettingsSaved } from "@/components/settings-signal";
|
||||||
|
|
||||||
|
// Shared stale-while-revalidate catalog. Metadata refreshes retain the last
|
||||||
|
// good snapshot; connection changes clear selectable models immediately.
|
||||||
|
let catalogCache: ModelCatalogResponse | undefined;
|
||||||
|
let catalogError = false;
|
||||||
|
let catalogInflight: Promise<void> | undefined;
|
||||||
|
let catalogGeneration = 0;
|
||||||
|
let stopCatalogSignals: (() => void) | undefined;
|
||||||
|
const catalogReaders = new Set<() => void>();
|
||||||
|
const publishCatalog = () => { for (const reader of catalogReaders) reader(); };
|
||||||
|
|
||||||
|
function fetchCatalog(revalidate = false): void {
|
||||||
|
if (revalidate) { catalogGeneration++; catalogInflight = undefined; }
|
||||||
|
if (catalogInflight) return;
|
||||||
|
const generation = catalogGeneration;
|
||||||
|
catalogError = false;
|
||||||
|
publishCatalog();
|
||||||
|
catalogInflight = fetch("/api/models")
|
||||||
|
.then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise<ModelCatalogResponse>; })
|
||||||
|
.then((c) => {
|
||||||
|
if (generation === catalogGeneration) { catalogCache = c; catalogError = false; publishCatalog(); }
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (generation === catalogGeneration) { catalogError = true; publishCatalog(); }
|
||||||
|
})
|
||||||
|
.finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; failed: boolean; refresh: () => void } {
|
||||||
|
const [snapshot, setSnapshot] = useState(() => ({ catalog: catalogCache, failed: catalogError }));
|
||||||
|
useEffect(() => {
|
||||||
|
const first = catalogReaders.size === 0;
|
||||||
|
const read = () => setSnapshot({ catalog: catalogCache, failed: catalogError });
|
||||||
|
catalogReaders.add(read);
|
||||||
|
if (first) {
|
||||||
|
stopCatalogSignals = onSettingsSaved((section) => {
|
||||||
|
if (section === "keys") {
|
||||||
|
catalogGeneration++; catalogInflight = undefined; catalogCache = undefined;
|
||||||
|
fetchCatalog();
|
||||||
|
} else if (section === "models") fetchCatalog(true);
|
||||||
|
});
|
||||||
|
fetchCatalog(true);
|
||||||
|
} else fetchCatalog();
|
||||||
|
read();
|
||||||
|
return () => { catalogReaders.delete(read); if (!catalogReaders.size) { stopCatalogSignals?.(); stopCatalogSignals = undefined; } };
|
||||||
|
}, []);
|
||||||
|
const refresh = useCallback(() => fetchCatalog(true), []);
|
||||||
|
return { ...snapshot, refresh };
|
||||||
|
}
|
||||||
+19
-59
@@ -8,52 +8,11 @@ import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-men
|
|||||||
import { composeSlotModel } from "@/components/settings/agent-section";
|
import { composeSlotModel } from "@/components/settings/agent-section";
|
||||||
import { MenuShell } from "@/components/menu-shell";
|
import { MenuShell } from "@/components/menu-shell";
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
import { notifySettingsSaved, onSettingsSaved, useSettingsSaved } from "@/components/settings-signal";
|
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
|
||||||
|
import { useModelCatalog } from "@/components/model-catalog";
|
||||||
|
export { useModelCatalog } from "@/components/model-catalog";
|
||||||
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
|
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
|
||||||
|
|
||||||
/** GET /api/models is cached per page load for instant menu opens, but
|
|
||||||
* the federation is LIVE per call — keys saved mid-session widen the
|
|
||||||
* catalog immediately server-side. ModelControl therefore re-fetches on
|
|
||||||
* every model-menu open (stale-while-revalidate: the cached list shows
|
|
||||||
* instantly, the fresh one swaps in while the menu is open). */
|
|
||||||
let catalogCache: ModelCatalogResponse | undefined;
|
|
||||||
let catalogInflight: Promise<void> | undefined;
|
|
||||||
let catalogGeneration = 0;
|
|
||||||
let stopCatalogSignals: (() => void) | undefined;
|
|
||||||
const catalogReaders = new Set<() => void>();
|
|
||||||
const publishCatalog = () => { for (const reader of catalogReaders) reader(); };
|
|
||||||
function fetchCatalog(revalidate = false): void {
|
|
||||||
if (revalidate) { catalogGeneration++; catalogInflight = undefined; }
|
|
||||||
if (catalogInflight) return;
|
|
||||||
const generation = catalogGeneration;
|
|
||||||
catalogInflight = fetch("/api/models")
|
|
||||||
.then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise<ModelCatalogResponse>; })
|
|
||||||
.then((c) => { if (generation === catalogGeneration) { catalogCache = c; publishCatalog(); } })
|
|
||||||
.catch(() => { /* existing unavailable/usage-only posture, no retry timer */ })
|
|
||||||
.finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; });
|
|
||||||
}
|
|
||||||
export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh: () => void } {
|
|
||||||
const [catalog, setCatalog] = useState(catalogCache);
|
|
||||||
useEffect(() => {
|
|
||||||
const first = catalogReaders.size === 0;
|
|
||||||
const read = () => setCatalog(catalogCache);
|
|
||||||
catalogReaders.add(read);
|
|
||||||
if (first) {
|
|
||||||
stopCatalogSignals = onSettingsSaved((section) => {
|
|
||||||
if (section === "keys") {
|
|
||||||
catalogGeneration++; catalogInflight = undefined; catalogCache = undefined;
|
|
||||||
publishCatalog(); fetchCatalog();
|
|
||||||
} else if (section === "models") fetchCatalog(true);
|
|
||||||
});
|
|
||||||
fetchCatalog(true);
|
|
||||||
} else fetchCatalog();
|
|
||||||
read();
|
|
||||||
return () => { catalogReaders.delete(read); if (!catalogReaders.size) { stopCatalogSignals?.(); stopCatalogSignals = undefined; } };
|
|
||||||
}, []);
|
|
||||||
const refresh = useCallback(() => fetchCatalog(true), []);
|
|
||||||
return { catalog, refresh };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** provider:id → contextWindow over the cached catalog — the client-side
|
/** provider:id → contextWindow over the cached catalog — the client-side
|
||||||
* twin of the server's catalogContextLimits join (context-meter
|
* twin of the server's catalogContextLimits join (context-meter
|
||||||
* amendment, owner 2026-09-23: pi's live catalog is the ONLY limit
|
* amendment, owner 2026-09-23: pi's live catalog is the ONLY limit
|
||||||
@@ -84,7 +43,9 @@ export function catalogLimits(catalog: Pick<ModelCatalogResponse, "models">): Ma
|
|||||||
export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) {
|
export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) {
|
||||||
const { catalog, refresh } = useModelCatalog();
|
const { catalog, refresh } = useModelCatalog();
|
||||||
const [models, setModels] = useState<ModelsSettings | undefined>();
|
const [models, setModels] = useState<ModelsSettings | undefined>();
|
||||||
const [session, setSession] = useState<ChatSettings | undefined>();
|
// A successful read with no settings is a legacy session; undefined
|
||||||
|
// means no session record is known yet, not permission to inherit Strong.
|
||||||
|
const [session, setSession] = useState<{ settings?: ChatSettings }>();
|
||||||
const [open, setOpen] = useState<"model" | "thinking" | null>(null);
|
const [open, setOpen] = useState<"model" | "thinking" | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -99,8 +60,8 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
|
|||||||
const readRecord = useCallback(() => {
|
const readRecord = useCallback(() => {
|
||||||
if (chatId) {
|
if (chatId) {
|
||||||
fetch(`/api/chats/${chatId}`)
|
fetch(`/api/chats/${chatId}`)
|
||||||
.then((r) => r.json() as Promise<{ settings?: ChatSettings }>)
|
.then((r) => { if (!r.ok) throw new Error(`session read failed: ${r.status}`); return r.json() as Promise<{ settings?: ChatSettings }>; })
|
||||||
.then((body) => setSession(body.settings))
|
.then((body) => setSession({ settings: body.settings }))
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* same read-only posture as a models-section failure */
|
/* same read-only posture as a models-section failure */
|
||||||
});
|
});
|
||||||
@@ -124,15 +85,17 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Dual-bound truth: chat mode displays the session's own record,
|
// Dual-bound truth: chat mode displays the session's own record,
|
||||||
// falling back to the strong slot for legacy unstamped sessions —
|
// falling back to Strong only for a successfully read legacy unstamped
|
||||||
// EXACTLY what resolveConversationSpec runs. Draft mode is the strong
|
// session — EXACTLY what resolveConversationSpec runs. An unresolved or
|
||||||
// slot itself.
|
// failed record read must not report a provisional global preference as
|
||||||
|
// this session's identity (including to the Send guard). Draft mode is
|
||||||
|
// the strong slot itself.
|
||||||
const strong = models?.strong;
|
const strong = models?.strong;
|
||||||
const override: ChatSettings | undefined = chatId
|
const override: ChatSettings | undefined = chatId
|
||||||
? {
|
? session ? {
|
||||||
model: session?.model ?? strong?.model,
|
model: session.settings?.model ?? strong?.model,
|
||||||
thinkingLevel: session?.thinkingLevel ?? strong?.thinkingLevel,
|
thinkingLevel: session.settings?.thinkingLevel ?? strong?.thinkingLevel,
|
||||||
}
|
} : undefined
|
||||||
: strong;
|
: strong;
|
||||||
const effectiveId = override?.model?.id;
|
const effectiveId = override?.model?.id;
|
||||||
const effectiveProvider = override?.model?.provider;
|
const effectiveProvider = override?.model?.provider;
|
||||||
@@ -236,7 +199,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const stored = (await res.json().catch(() => null)) as { settings?: ChatSettings } | null;
|
const stored = (await res.json().catch(() => null)) as { settings?: ChatSettings } | null;
|
||||||
setSession(stored?.settings ?? next);
|
setSession({ settings: stored?.settings ?? next });
|
||||||
setOpen(null);
|
setOpen(null);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Could not reach the server — try again.");
|
setError("Could not reach the server — try again.");
|
||||||
@@ -315,10 +278,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
|
|||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative flex items-center gap-1.5" data-testid="model-control">
|
<div ref={containerRef} className="relative flex items-center gap-1.5" data-testid="model-control">
|
||||||
<Hint
|
<Hint
|
||||||
// Trimmed (owner, subtlety round 2026-09-25): "Model selector"
|
label={effectiveId ? `${effectiveProvider ? `${effectiveProvider}:` : ""}${effectiveId}` : "Model selector"}
|
||||||
// — the long sentence was way too much. ⌘K opens this same
|
|
||||||
// menu (registry, override-aware).
|
|
||||||
label="Model selector"
|
|
||||||
action="open-model-picker"
|
action="open-model-picker"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
>
|
>
|
||||||
|
|||||||
+12
-14
@@ -8,7 +8,7 @@ import { DictationBar, joinDictation, useDictation } from "@/components/dictatio
|
|||||||
import { useFieldCycle } from "@/components/field-cycle";
|
import { useFieldCycle } from "@/components/field-cycle";
|
||||||
import { SidebarToggle } from "@/components/sidebar";
|
import { SidebarToggle } from "@/components/sidebar";
|
||||||
import { SetupDialog } from "@/components/setup-dialog";
|
import { SetupDialog } from "@/components/setup-dialog";
|
||||||
import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
|
import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta";
|
||||||
import { StatsBar } from "@/components/stats-bar";
|
import { StatsBar } from "@/components/stats-bar";
|
||||||
import { ChainTableOverlay } from "@/components/chain-table.tsx";
|
import { ChainTableOverlay } from "@/components/chain-table.tsx";
|
||||||
import { ComposerActions, NodePickerControl } from "@/components/composer-actions";
|
import { ComposerActions, NodePickerControl } from "@/components/composer-actions";
|
||||||
@@ -279,14 +279,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
|
|||||||
// 33 providers, chats cannot start. The draft stays in the textarea;
|
// 33 providers, chats cannot start. The draft stays in the textarea;
|
||||||
// Save stores the slots and never sends (owner ruling 2026-10-09) —
|
// Save stores the slots and never sends (owner ruling 2026-10-09) —
|
||||||
// the learner sends the draft themselves.
|
// the learner sends the draft themselves.
|
||||||
const [configured, setConfigured] = useState(initialConfigured);
|
const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured);
|
||||||
// The zero-keys truth (zero-keys wave): no provider key = the pill
|
|
||||||
// takes precedence over the models CTA (keys come first in the chain).
|
|
||||||
const zeroKeys = useZeroKeys();
|
|
||||||
// The strong slot's keyless truth (pre-send key guards, owner interlude
|
|
||||||
// 2026-09-25): keys may exist generally while the STRONG slot's pinned
|
|
||||||
// provider has none — the same skip-everything treatment on send.
|
|
||||||
const strongKeyless = useStrongKeyless();
|
|
||||||
const [setupOpen, setSetupOpen] = useState(false);
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
|
|
||||||
const send = useCallback(() => {
|
const send = useCallback(() => {
|
||||||
@@ -299,12 +292,18 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
|
|||||||
// The pre-send KEY guards (owner interlude, no-orphan-chats): guard
|
// The pre-send KEY guards (owner interlude, no-orphan-chats): guard
|
||||||
// BEFORE startDraftSession creates the record — a refused first
|
// BEFORE startDraftSession creates the record — a refused first
|
||||||
// send would otherwise leave an orphan error-shell chat in the
|
// send would otherwise leave an orphan error-shell chat in the
|
||||||
// sidebar. Same skip-everything-and-open-setup treatment; the
|
// sidebar. Zero providers opens connection setup; an unavailable
|
||||||
// server's 503 gate remains the belt for /chat/[id] sessions.
|
// selection with another provider opens the ordinary Strong picker.
|
||||||
if (zeroKeys || strongKeyless) {
|
// The server's admission gate remains the belt.
|
||||||
|
if (zeroKeys) {
|
||||||
setSetupOpen(true);
|
setSetupOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const strong = catalog?.slots.strong?.model;
|
||||||
|
if (selectedModelUnavailable(catalog, strong ? `${strong.provider}:${strong.id}` : undefined)) {
|
||||||
|
openModelPicker();
|
||||||
|
return; // The learner replaces it explicitly, then sends again.
|
||||||
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined)
|
void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined)
|
||||||
.then((target) => {
|
.then((target) => {
|
||||||
@@ -312,7 +311,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
|
|||||||
else setBusy(false);
|
else setBusy(false);
|
||||||
})
|
})
|
||||||
.catch(() => setBusy(false));
|
.catch(() => setBusy(false));
|
||||||
}, [busy, router, configured, zeroKeys, strongKeyless]);
|
}, [busy, router, configured, zeroKeys, catalog]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -373,7 +372,6 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
|
|||||||
open={setupOpen}
|
open={setupOpen}
|
||||||
onClose={() => setSetupOpen(false)}
|
onClose={() => setSetupOpen(false)}
|
||||||
onDone={() => {
|
onDone={() => {
|
||||||
setConfigured(true);
|
|
||||||
setSetupOpen(false);
|
setSetupOpen(false);
|
||||||
// Save only saves (owner ruling 2026-10-09): the draft
|
// Save only saves (owner ruling 2026-10-09): the draft
|
||||||
// waits in the composer; the learner sends it.
|
// waits in the composer; the learner sends it.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { MessageResponse } from "@/components/ai-elements/message";
|
import { ToolContentResponse } from "@/components/ai-elements/message";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { repairMathForDisplay, type ArtifactPartProps } from "@/src/core";
|
import type { ArtifactPartProps } from "@/src/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The practice transcript cards (practice sessions, owner design
|
* The practice transcript cards (practice sessions, owner design
|
||||||
@@ -66,7 +66,7 @@ export function ProblemCard({ part }: ArtifactPartProps) {
|
|||||||
<div data-testid="problem-card" className="lh-card-enter my-1 rounded-xl border bg-card p-4">
|
<div data-testid="problem-card" className="lh-card-enter my-1 rounded-xl border bg-card p-4">
|
||||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
||||||
<div className="lh-prose text-[15px]">
|
<div className="lh-prose text-[15px]">
|
||||||
<MessageResponse>{repairMathForDisplay(statement)}</MessageResponse>
|
<ToolContentResponse>{statement}</ToolContentResponse>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -133,7 +133,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
|
|||||||
(owner round 3): the same quiet 50% register as quiz explanations. */}
|
(owner round 3): the same quiet 50% register as quiz explanations. */}
|
||||||
{verdict.length > 0 && (
|
{verdict.length > 0 && (
|
||||||
<div className="text-sm text-foreground/50">
|
<div className="text-sm text-foreground/50">
|
||||||
<MessageResponse>{repairMathForDisplay(verdict)}</MessageResponse>
|
<ToolContentResponse>{verdict}</ToolContentResponse>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Then the divider, then the labeled model solution (Slice G: the
|
{/* Then the divider, then the labeled model solution (Slice G: the
|
||||||
@@ -144,7 +144,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
|
|||||||
<div>
|
<div>
|
||||||
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Model solution</div>
|
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Model solution</div>
|
||||||
<div className="lh-prose text-[15px]">
|
<div className="lh-prose text-[15px]">
|
||||||
<MessageResponse>{repairMathForDisplay(reveal)}</MessageResponse>
|
<ToolContentResponse>{reveal}</ToolContentResponse>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
|
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { ChevronRightIcon } from "lucide-react";
|
import { ChevronRightIcon } from "lucide-react";
|
||||||
import { MessageResponse } from "@/components/ai-elements/message";
|
import { ToolContentResponse } from "@/components/ai-elements/message";
|
||||||
import { Button } from "@/components/ui/button";
|
import { 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 { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { repairMathForDisplay } from "@/src/core";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The practice dock (practice sessions, owner design 2026-10-04): an
|
* The practice dock (practice sessions, owner design 2026-10-04): an
|
||||||
@@ -106,10 +105,10 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
|||||||
cards' label row — owner round, 2026-10-05) + the statement,
|
cards' label row — owner round, 2026-10-05) + the statement,
|
||||||
markdown + LaTeX rendered, capped by the measured column
|
markdown + LaTeX rendered, capped by the measured column
|
||||||
(--dock-max-h; the old fixed 40vh cut long problems short). */}
|
(--dock-max-h; the old fixed 40vh cut long problems short). */}
|
||||||
<div className="max-h-[var(--dock-max-h,40vh)] overflow-y-auto px-4 pt-3 pb-2">
|
<div data-dock-scroll-area="" className="max-h-[var(--dock-max-h,40vh)] overflow-y-auto px-4 pt-3 pb-2">
|
||||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
||||||
<div className="lh-prose text-[15px]">
|
<div className="lh-prose text-[15px]">
|
||||||
<MessageResponse>{typeof input.statement === "string" ? repairMathForDisplay(input.statement) : input.statement}</MessageResponse>
|
<ToolContentResponse>{input.statement}</ToolContentResponse>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ 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 { MessageResponse } from "@/components/ai-elements/message";
|
import { ToolContentResponse } from "@/components/ai-elements/message";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
buildAnswer,
|
buildAnswer,
|
||||||
|
repairMathForDisplay,
|
||||||
normalizeQuizType,
|
normalizeQuizType,
|
||||||
CONFIDENCE_STOPS,
|
CONFIDENCE_STOPS,
|
||||||
DEFAULT_CONFIDENCE_INDEX,
|
DEFAULT_CONFIDENCE_INDEX,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
type DockSelection,
|
type DockSelection,
|
||||||
type ExplainToolInput,
|
type ExplainToolInput,
|
||||||
type QuestionAnswerData,
|
type QuestionAnswerData,
|
||||||
|
type QuestionFormDraft,
|
||||||
type QuestionData,
|
type QuestionData,
|
||||||
type QuestionMode,
|
type QuestionMode,
|
||||||
type QuizToolInput,
|
type QuizToolInput,
|
||||||
@@ -71,6 +73,9 @@ export interface QuestionDockProps {
|
|||||||
reveal?: boolean;
|
reveal?: boolean;
|
||||||
onExpand: () => void;
|
onExpand: () => void;
|
||||||
onSubmit: (output: QuestionAnswerData) => void;
|
onSubmit: (output: QuestionAnswerData) => void;
|
||||||
|
/** Restore only this call's unconfirmed submission after recovery. */
|
||||||
|
initialDraft?: QuestionFormDraft;
|
||||||
|
onSaveDraft?: (draft: QuestionFormDraft) => void;
|
||||||
/** Escape in a question field stops dictation (STT wave) — threaded
|
/** Escape in a question field stops dictation (STT wave) — threaded
|
||||||
* from the page that owns the dictation hook. */
|
* from the page that owns the dictation hook. */
|
||||||
onEscape?: () => void;
|
onEscape?: () => void;
|
||||||
@@ -100,6 +105,8 @@ export function QuestionDock({
|
|||||||
reveal = false,
|
reveal = false,
|
||||||
onExpand,
|
onExpand,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
initialDraft,
|
||||||
|
onSaveDraft,
|
||||||
onEscape,
|
onEscape,
|
||||||
phase = "probe",
|
phase = "probe",
|
||||||
dictationClaim = true,
|
dictationClaim = true,
|
||||||
@@ -155,6 +162,8 @@ export function QuestionDock({
|
|||||||
phase={phase}
|
phase={phase}
|
||||||
active={expanded}
|
active={expanded}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
initialDraft={initialDraft}
|
||||||
|
onSaveDraft={onSaveDraft}
|
||||||
onEscape={onEscape}
|
onEscape={onEscape}
|
||||||
dictationClaim={dictationClaim}
|
dictationClaim={dictationClaim}
|
||||||
compactionRunning={compactionRunning}
|
compactionRunning={compactionRunning}
|
||||||
@@ -166,7 +175,7 @@ export function QuestionDock({
|
|||||||
|
|
||||||
/** A plain-text teaser of a question for the collapsed bar: unwrap math, flatten common LaTeX. */
|
/** A plain-text teaser of a question for the collapsed bar: unwrap math, flatten common LaTeX. */
|
||||||
function teaserText(q: string): string {
|
function teaserText(q: string): string {
|
||||||
return q
|
return repairMathForDisplay(q)
|
||||||
.replace(/\\\(([\s\S]*?)\\\)/g, "$1") // \(...\) inline math
|
.replace(/\\\(([\s\S]*?)\\\)/g, "$1") // \(...\) inline math
|
||||||
.replace(/\\\[([\s\S]*?)\\\]/g, "$1") // \[...\] display math
|
.replace(/\\\[([\s\S]*?)\\\]/g, "$1") // \[...\] display math
|
||||||
.replace(/\$\$?([^$]*)\$\$?/g, "$1") // standard $...$/$$...$$ math
|
.replace(/\$\$?([^$]*)\$\$?/g, "$1") // standard $...$/$$...$$ math
|
||||||
@@ -250,6 +259,8 @@ function QuestionDockForm({
|
|||||||
phase = "probe",
|
phase = "probe",
|
||||||
active,
|
active,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
initialDraft,
|
||||||
|
onSaveDraft,
|
||||||
onEscape,
|
onEscape,
|
||||||
dictationClaim = true,
|
dictationClaim = true,
|
||||||
compactionRunning = false,
|
compactionRunning = false,
|
||||||
@@ -311,22 +322,22 @@ function QuestionDockForm({
|
|||||||
// single-select displaces, multi-select coexists — picking the ask
|
// single-select displaces, multi-select coexists — picking the ask
|
||||||
// "Other…" row in a multi-select must never clear already-picked
|
// "Other…" row in a multi-select must never clear already-picked
|
||||||
// options, and picking an option must never clear a typed custom answer.
|
// options, and picking an option must never clear a typed custom answer.
|
||||||
const [sel, setSel] = useState<DockSelection>(emptyDockSelection());
|
const [sel, setSel] = useState<DockSelection>(() => initialDraft?.selection ?? emptyDockSelection());
|
||||||
// The learner's transfer channel — thinking, never "a note".
|
// The learner's transfer channel — thinking, never "a note".
|
||||||
const [thinking, setThinking] = useState("");
|
const [thinking, setThinking] = useState(initialDraft?.thinking ?? "");
|
||||||
const [other, setOther] = useState("");
|
const [other, setOther] = useState(initialDraft?.other ?? "");
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState(initialDraft?.text ?? "");
|
||||||
/* Refs MIRROR the two dictation-target fields ("nothing gets lost"
|
/* Refs MIRROR the two dictation-target fields ("nothing gets lost"
|
||||||
* round, owner 2026-10-01): the run's flush lands via setState, which
|
* round, owner 2026-10-01): the run's flush lands via setState, which
|
||||||
* a closure waiting through `await finishDictation()` cannot observe
|
* a closure waiting through `await finishDictation()` cannot observe
|
||||||
* in time — submit reads these, kept current at every write site. */
|
* in time — submit reads these, kept current at every write site. */
|
||||||
const thinkingTextRef = useRef("");
|
const thinkingTextRef = useRef(initialDraft?.thinking ?? "");
|
||||||
const answerTextRef = useRef("");
|
const answerTextRef = useRef(initialDraft?.text ?? "");
|
||||||
// Confidence (owner, 2026-10-01): an index into CONFIDENCE_STOPS,
|
// Confidence (owner, 2026-10-01): an index into CONFIDENCE_STOPS,
|
||||||
// resting at 50 — a real coin flip by definition, untouched or
|
// resting at 50 — a real coin flip by definition, untouched or
|
||||||
// deliberate (the same number, the same meaning). The dock unmounts
|
// deliberate (the same number, the same meaning). The dock unmounts
|
||||||
// between questions, so each question starts fresh at 50.
|
// between questions, so each question starts fresh at 50.
|
||||||
const [confIdx, setConfIdx] = useState<number>(DEFAULT_CONFIDENCE_INDEX);
|
const [confIdx, setConfIdx] = useState<number>(initialDraft?.confidenceIndex ?? DEFAULT_CONFIDENCE_INDEX);
|
||||||
const selectMode: SelectMode = qd.mode === "multi-select" ? "multi-select" : "single-select";
|
const selectMode: SelectMode = qd.mode === "multi-select" ? "multi-select" : "single-select";
|
||||||
|
|
||||||
// The PRIMARY dictation input (owner, STT round 1): while a question
|
// The PRIMARY dictation input (owner, STT round 1): while a question
|
||||||
@@ -390,6 +401,15 @@ function QuestionDockForm({
|
|||||||
// "untouched"); buildAnswer drops it on "I don't know".
|
// "untouched"); buildAnswer drops it on "I don't know".
|
||||||
confidence: kind === "quiz" ? CONFIDENCE_STOPS[confIdx] : undefined,
|
confidence: kind === "quiz" ? CONFIDENCE_STOPS[confIdx] : undefined,
|
||||||
};
|
};
|
||||||
|
// Snapshot before optimistic submission unmounts the form. Ref values
|
||||||
|
// include the dictation finals that just landed, unlike stale closures.
|
||||||
|
onSaveDraft?.({
|
||||||
|
selection: { ...sel, selected: new Set(sel.selected) },
|
||||||
|
thinking: thinkingTextRef.current,
|
||||||
|
other,
|
||||||
|
text: answerTextRef.current,
|
||||||
|
confidenceIndex: confIdx,
|
||||||
|
});
|
||||||
onSubmit(buildAnswer(qd, req, toolName));
|
onSubmit(buildAnswer(qd, req, toolName));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -544,7 +564,7 @@ function QuestionDockForm({
|
|||||||
{/* Question area — scrolls internally if very tall; the bottom row stays put.
|
{/* Question area — scrolls internally if very tall; the bottom row stays put.
|
||||||
Capped by the measured column (--dock-max-h; the old fixed 55dvh cut
|
Capped by the measured column (--dock-max-h; the old fixed 55dvh cut
|
||||||
long questions short — owner round, 2026-10-05). */}
|
long questions short — owner round, 2026-10-05). */}
|
||||||
<div data-session-find-root="" className="max-h-[var(--dock-max-h,55dvh)] overflow-y-auto px-4 pt-3">
|
<div data-dock-scroll-area="" data-session-find-root="" className="max-h-[var(--dock-max-h,55dvh)] overflow-y-auto px-4 pt-3">
|
||||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
{toolName === "probe"
|
{toolName === "probe"
|
||||||
? "Probe"
|
? "Probe"
|
||||||
@@ -560,11 +580,11 @@ function QuestionDockForm({
|
|||||||
{qd.mode === "multi-select" && " · select all that apply"}
|
{qd.mode === "multi-select" && " · select all that apply"}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3 font-medium">
|
<div className="mb-3 font-medium">
|
||||||
<MessageResponse>{qd.question}</MessageResponse>
|
<ToolContentResponse>{qd.question}</ToolContentResponse>
|
||||||
</div>
|
</div>
|
||||||
{qd.context && (
|
{qd.context && (
|
||||||
<div className="mb-3 text-sm text-muted-foreground">
|
<div className="mb-3 text-sm text-muted-foreground">
|
||||||
<MessageResponse>{qd.context}</MessageResponse>
|
<ToolContentResponse>{qd.context}</ToolContentResponse>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -600,7 +620,7 @@ function QuestionDockForm({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<MessageResponse>{opt.label}</MessageResponse>
|
<ToolContentResponse>{opt.label}</ToolContentResponse>
|
||||||
{kind !== "quiz" && opt.description && (
|
{kind !== "quiz" && opt.description && (
|
||||||
// Option subtext renders in Source Sans 3 (owner's mockup);
|
// Option subtext renders in Source Sans 3 (owner's mockup);
|
||||||
// the "I don't know" copy stays JetBrains Mono. QUIZ options render
|
// the "I don't know" copy stays JetBrains Mono. QUIZ options render
|
||||||
@@ -609,9 +629,9 @@ function QuestionDockForm({
|
|||||||
// records must not leak either. Same MessageResponse path as the
|
// records must not leak either. Same MessageResponse path as the
|
||||||
// label (owner, 2026-10-12): descriptions are full markdown —
|
// label (owner, 2026-10-12): descriptions are full markdown —
|
||||||
// LaTeX included — not plain text.
|
// LaTeX included — not plain text.
|
||||||
<MessageResponse className="lh-prose block text-xs text-muted-foreground">
|
<ToolContentResponse className="lh-prose block text-xs text-muted-foreground">
|
||||||
{opt.description}
|
{opt.description}
|
||||||
</MessageResponse>
|
</ToolContentResponse>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ export function wireSource(source: NotifySource, hub: ReturnType<typeof createNo
|
|||||||
|
|
||||||
const EventsContext = createContext<ReturnType<typeof createNotifyHub> | null>(null);
|
const EventsContext = createContext<ReturnType<typeof createNotifyHub> | null>(null);
|
||||||
|
|
||||||
|
/** Transport adapters may also be mounted in isolation with an injected bridge. */
|
||||||
|
export function useNotifyHub() { return useContext(EventsContext); }
|
||||||
|
|
||||||
export function ServerEventsProvider({ children, connect }: { children: ReactNode; connect?: () => NotifySource }) {
|
export function ServerEventsProvider({ children, connect }: { children: ReactNode; connect?: () => NotifySource }) {
|
||||||
const hub = useMemo(() => createNotifyHub(), []);
|
const hub = useMemo(() => createNotifyHub(), []);
|
||||||
useEffect(() => wireSource((connect ?? openEventsChannel)(), hub), [hub, connect]);
|
useEffect(() => wireSource((connect ?? openEventsChannel)(), hub), [hub, connect]);
|
||||||
|
|||||||
+173
-28
@@ -17,6 +17,8 @@ import type { AgentEvent } from "@earendil-works/pi-agent-core";
|
|||||||
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
import {
|
import {
|
||||||
needsContinuation,
|
needsContinuation,
|
||||||
|
danglingQuestionOf,
|
||||||
|
answeredCallIds,
|
||||||
measuredContextTokens,
|
measuredContextTokens,
|
||||||
newAnswerResult,
|
newAnswerResult,
|
||||||
newNextMessage,
|
newNextMessage,
|
||||||
@@ -30,6 +32,7 @@ import {
|
|||||||
type LhViewMessage,
|
type LhViewMessage,
|
||||||
type LiveExecution,
|
type LiveExecution,
|
||||||
type QuestionAnswerData,
|
type QuestionAnswerData,
|
||||||
|
type QuestionDraft,
|
||||||
} from "../src/core";
|
} from "../src/core";
|
||||||
|
|
||||||
export type SessionStatus = "ready" | "submitted" | "streaming" | "error";
|
export type SessionStatus = "ready" | "submitted" | "streaming" | "error";
|
||||||
@@ -78,13 +81,25 @@ async function turnInFlight(model: SessionModel): Promise<boolean | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One wire event off the SSE body: parsed JSON, or the [DONE] terminal. */
|
const STREAM_DROPPED = "The connection dropped before the turn completed.";
|
||||||
async function* sseEvents(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncGenerator<Record<string, unknown>> {
|
|
||||||
|
/** Mark transport failures without confusing JSON/application bugs with drops. */
|
||||||
|
class StreamReadError extends Error {
|
||||||
|
constructor(cause: unknown) {
|
||||||
|
super(cause instanceof Error ? cause.message : String(cause), { cause });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wire events; return true ONLY for the explicit completion marker. */
|
||||||
|
async function* sseEvents(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncGenerator<Record<string, unknown>, boolean> {
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buf = "";
|
let buf = "";
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const { done, value } = await reader.read();
|
let chunk: ReadableStreamReadResult<Uint8Array>;
|
||||||
if (done) return;
|
try { chunk = await reader.read(); }
|
||||||
|
catch (err) { throw new StreamReadError(err); }
|
||||||
|
const { done, value } = chunk;
|
||||||
|
if (done) return false;
|
||||||
buf += decoder.decode(value, { stream: true });
|
buf += decoder.decode(value, { stream: true });
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const end = buf.indexOf("\n\n");
|
const end = buf.indexOf("\n\n");
|
||||||
@@ -94,7 +109,7 @@ async function* sseEvents(reader: ReadableStreamDefaultReader<Uint8Array>): Asyn
|
|||||||
const dataLine = event.split("\n").find((l) => l.startsWith("data: "));
|
const dataLine = event.split("\n").find((l) => l.startsWith("data: "));
|
||||||
if (!dataLine) continue;
|
if (!dataLine) continue;
|
||||||
const data = dataLine.slice("data: ".length);
|
const data = dataLine.slice("data: ".length);
|
||||||
if (data === "[DONE]") return;
|
if (data === "[DONE]") return true;
|
||||||
yield JSON.parse(data) as Record<string, unknown>;
|
yield JSON.parse(data) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,6 +211,8 @@ export function visibleQueuedSteers(
|
|||||||
export interface SessionModelOptions extends SessionModelDeps {
|
export interface SessionModelOptions extends SessionModelDeps {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
initialMessages: LhMessage[];
|
initialMessages: LhMessage[];
|
||||||
|
/** Retained, memory-only server failure when reopening a session. */
|
||||||
|
initialError?: string;
|
||||||
/** Fires on every state change (the hook re-renders). */
|
/** Fires on every state change (the hook re-renders). */
|
||||||
onChange?: () => void;
|
onChange?: () => void;
|
||||||
/** The reattach settled terminal (turn-settled): no turn running, the
|
/** The reattach settled terminal (turn-settled): no turn running, the
|
||||||
@@ -220,6 +237,9 @@ export class SessionModel {
|
|||||||
// at every full sync; the write-ahead absorbs the optimistic entries.
|
// at every full sync; the write-ahead absorbs the optimistic entries.
|
||||||
private base: LhMessage[];
|
private base: LhMessage[];
|
||||||
private tail: LhMessage[] = [];
|
private tail: LhMessage[] = [];
|
||||||
|
private questionDrafts = new Map<string, QuestionDraft>();
|
||||||
|
/** Only learner answers submitted through send(), never silent dismissals. */
|
||||||
|
private unconfirmedAnswers = new Map<string, string>();
|
||||||
// The live turn's execution layer (view model mid-turn states):
|
// The live turn's execution layer (view model mid-turn states):
|
||||||
private executions = new Map<string, LiveExecution>();
|
private executions = new Map<string, LiveExecution>();
|
||||||
private streamingIds = new Set<string>();
|
private streamingIds = new Set<string>();
|
||||||
@@ -229,6 +249,8 @@ export class SessionModel {
|
|||||||
private statusValue: SessionStatus = "ready";
|
private statusValue: SessionStatus = "ready";
|
||||||
/** Request/loop failures are transient; provider failures derive from messages. */
|
/** Request/loop failures are transient; provider failures derive from messages. */
|
||||||
private errorValue: { message: string } | null = null;
|
private errorValue: { message: string } | null = null;
|
||||||
|
/** Dismissing a banner must not authorize an automatic replacement turn. */
|
||||||
|
private automaticContinuationBlocked = false;
|
||||||
/** A completed failure from THIS consume, not the previous attempt's record. */
|
/** A completed failure from THIS consume, not the previous attempt's record. */
|
||||||
private liveFailureId: string | null = null;
|
private liveFailureId: string | null = null;
|
||||||
// The single live consume (POST response or reattach stream): a newer
|
// The single live consume (POST response or reattach stream): a newer
|
||||||
@@ -240,6 +262,8 @@ export class SessionModel {
|
|||||||
constructor(opts: SessionModelOptions) {
|
constructor(opts: SessionModelOptions) {
|
||||||
this.chatId = opts.chatId;
|
this.chatId = opts.chatId;
|
||||||
this.base = opts.initialMessages;
|
this.base = opts.initialMessages;
|
||||||
|
this.errorValue = opts.initialError === undefined ? null : { message: opts.initialError };
|
||||||
|
this.automaticContinuationBlocked = opts.initialError !== undefined;
|
||||||
// BOUND: the browser's native fetch is a Window method — storing it
|
// BOUND: the browser's native fetch is a Window method — storing it
|
||||||
// unbound makes every call throw "Illegal invocation" in the browser
|
// unbound makes every call throw "Illegal invocation" in the browser
|
||||||
// (Node's undici doesn't care, so the tests never saw it). Always go
|
// (Node's undici doesn't care, so the tests never saw it). Always go
|
||||||
@@ -300,6 +324,7 @@ export class SessionModel {
|
|||||||
|
|
||||||
private setError(message: string | null): void {
|
private setError(message: string | null): void {
|
||||||
this.errorValue = message === null ? null : { message };
|
this.errorValue = message === null ? null : { message };
|
||||||
|
if (message !== null) this.automaticContinuationBlocked = true;
|
||||||
this.onChange?.();
|
this.onChange?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +371,12 @@ export class SessionModel {
|
|||||||
}
|
}
|
||||||
const agent = event as unknown as AgentEvent;
|
const agent = event as unknown as AgentEvent;
|
||||||
switch (agent.type) {
|
switch (agent.type) {
|
||||||
|
case "agent_start":
|
||||||
|
// Positive evidence of a new/replayed server-owned turn. Any
|
||||||
|
// later failure in that replay blocks continuation again.
|
||||||
|
this.automaticContinuationBlocked = false;
|
||||||
|
if (this.errorValue !== null) this.setError(null);
|
||||||
|
return false;
|
||||||
case "message_start":
|
case "message_start":
|
||||||
case "message_update":
|
case "message_update":
|
||||||
case "message_end": {
|
case "message_end": {
|
||||||
@@ -445,18 +476,28 @@ export class SessionModel {
|
|||||||
|
|
||||||
/** Consume one SSE body to its [DONE] terminal. The turn-settled
|
/** Consume one SSE body to its [DONE] terminal. The turn-settled
|
||||||
* terminal (stream-route only) fires the settle hook and ends. */
|
* terminal (stream-route only) fires the settle hook and ends. */
|
||||||
async consumeStream(res: Response, controller = this.consumeAbort): Promise<void> {
|
async consumeStream(res: Response, controller = this.consumeAbort, allowRecovery = true): Promise<void> {
|
||||||
if (!res.body) throw new Error("The turn stream body is empty.");
|
if (!res.body) throw new Error("The turn stream body is empty.");
|
||||||
const reader = res.body.getReader();
|
const reader = res.body.getReader();
|
||||||
let sawEvent = false;
|
let sawEvent = false;
|
||||||
|
let sawDone = false;
|
||||||
try {
|
try {
|
||||||
for await (const event of sseEvents(reader)) {
|
const events = sseEvents(reader);
|
||||||
|
for (;;) {
|
||||||
|
let next: IteratorResult<Record<string, unknown>, boolean>;
|
||||||
|
try { next = await events.next(); }
|
||||||
|
catch (err) {
|
||||||
|
if (err instanceof StreamReadError) break;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
if (this.consumeAbort !== controller) return;
|
if (this.consumeAbort !== controller) return;
|
||||||
|
if (next.done) { sawDone = next.value; break; }
|
||||||
|
const event = next.value;
|
||||||
if (event.type === "turn-settled") {
|
if (event.type === "turn-settled") {
|
||||||
// The raced-settle terminal: no turn to attach to — the
|
// The raced-settle terminal: no turn to attach to — the
|
||||||
// record is current; the settle hook refetches.
|
// record is current; the settle hook refetches.
|
||||||
await reader.cancel().catch(() => {});
|
sawDone = true;
|
||||||
if (this.consumeAbort === controller) this.onReattachSettled?.();
|
this.onReattachSettled?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const acted = this.applyWireEvent(event);
|
const acted = this.applyWireEvent(event);
|
||||||
@@ -466,14 +507,32 @@ export class SessionModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
reader.releaseLock();
|
||||||
// A cancelled consume may finish after its replacement has entered
|
// A cancelled consume may finish after its replacement has entered
|
||||||
// Thinking. Only the current owner can settle shared state.
|
// Thinking. Only the current owner can settle shared state.
|
||||||
if (this.consumeAbort === controller) {
|
if (this.consumeAbort === controller) {
|
||||||
this.streamingIds = new Set();
|
this.streamingIds = new Set();
|
||||||
this.executions = new Map();
|
this.executions = new Map();
|
||||||
if (this.statusValue !== "error") this.setStatus("ready");
|
if (sawDone && this.statusValue !== "error") this.setStatus("ready");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (this.consumeAbort !== controller || sawDone) return;
|
||||||
|
if (!allowRecovery) throw new StreamReadError(new Error(STREAM_DROPPED));
|
||||||
|
// One read-only recovery: refresh the canonical base, then replay the
|
||||||
|
// SAME server-owned turn. Never resend the learner's input.
|
||||||
|
await this.resumeStream(true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchView(url: string, signal: AbortSignal, recoveringDrop: boolean): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const res = await this.fetch(url, { signal });
|
||||||
|
if (recoveringDrop && !res.ok) throw new Error((await res.text()) || STREAM_DROPPED);
|
||||||
|
return res;
|
||||||
|
} catch (err) {
|
||||||
|
if (recoveringDrop && !signal.aborted) throw new StreamReadError(err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -489,7 +548,7 @@ export class SessionModel {
|
|||||||
* lands on top. The stream then replays the turn from its first event
|
* lands on top. The stream then replays the turn from its first event
|
||||||
* through the SAME consume path.
|
* through the SAME consume path.
|
||||||
*/
|
*/
|
||||||
async resumeStream(refetchRecordFirst = true): Promise<void> {
|
async resumeStream(refetchRecordFirst = true, allowRecovery = true): Promise<void> {
|
||||||
this.consumeAbort?.abort();
|
this.consumeAbort?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
this.consumeAbort = controller;
|
this.consumeAbort = controller;
|
||||||
@@ -519,16 +578,27 @@ export class SessionModel {
|
|||||||
this.setStatus("submitted");
|
this.setStatus("submitted");
|
||||||
try {
|
try {
|
||||||
if (refetchRecordFirst) {
|
if (refetchRecordFirst) {
|
||||||
const recordRes = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, {
|
const recordRes = await this.fetchView(`/api/chats/${encodeURIComponent(this.chatId)}`, controller.signal, !allowRecovery);
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
if (this.consumeAbort !== controller) return;
|
if (this.consumeAbort !== controller) return;
|
||||||
if (recordRes.ok) {
|
if (recordRes.ok) {
|
||||||
const record = (await recordRes.json()) as { messages?: LhMessage[] };
|
let text: string;
|
||||||
|
try { text = await recordRes.text(); }
|
||||||
|
catch (err) {
|
||||||
|
if (!allowRecovery && !controller.signal.aborted) throw new StreamReadError(err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// Parse outside the transport catch: malformed JSON is a
|
||||||
|
// contract bug, not a connection-drop diagnostic.
|
||||||
|
const record = JSON.parse(text) as { messages?: LhMessage[]; turnError?: string };
|
||||||
if (this.consumeAbort !== controller) return;
|
if (this.consumeAbort !== controller) return;
|
||||||
if (Array.isArray(record.messages)) {
|
if (Array.isArray(record.messages)) {
|
||||||
this.mutate(() => {
|
this.mutate(() => {
|
||||||
this.base = record.messages!;
|
this.base = record.messages!;
|
||||||
|
if (record.turnError !== undefined) {
|
||||||
|
this.errorValue = { message: record.turnError };
|
||||||
|
this.automaticContinuationBlocked = true;
|
||||||
|
}
|
||||||
|
this.forgetResolvedDrafts(record.messages!);
|
||||||
this.incompleteUsageIds.clear();
|
this.incompleteUsageIds.clear();
|
||||||
this.tail = [];
|
this.tail = [];
|
||||||
});
|
});
|
||||||
@@ -543,15 +613,19 @@ export class SessionModel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, {
|
const res = await this.fetchView(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, controller.signal, !allowRecovery);
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
if (this.consumeAbort !== controller) return;
|
if (this.consumeAbort !== controller) return;
|
||||||
if (!res.ok) throw new Error((await res.text()) || "Failed to attach to the running turn.");
|
if (!res.ok) throw new Error((await res.text()) || "Failed to attach to the running turn.");
|
||||||
await this.consumeStream(res, controller);
|
await this.consumeStream(res, controller, allowRecovery);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this.consumeAbort !== controller || (err as Error).name === "AbortError") return; // a newer consume won
|
if (this.consumeAbort !== controller || controller.signal.aborted) return; // intentionally cancelled
|
||||||
this.setError((err as Error).message);
|
// A failed record read can leave the optimistic answer in the tail.
|
||||||
|
// Restore its Submit affordance; the next attempt reconciles first.
|
||||||
|
this.mutate(() => {
|
||||||
|
this.tail = this.tail.filter(m => !this.unconfirmedAnswers.has(m.id));
|
||||||
|
this.unconfirmedAnswers.clear();
|
||||||
|
});
|
||||||
|
this.setError(!allowRecovery && err instanceof StreamReadError ? STREAM_DROPPED : (err as Error).message);
|
||||||
this.setStatus("error");
|
this.setStatus("error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -563,12 +637,42 @@ export class SessionModel {
|
|||||||
* server's 409 + identity retry covers the wake-race window).
|
* server's 409 + identity retry covers the wake-race window).
|
||||||
*/
|
*/
|
||||||
async send(input: ChatInput): Promise<boolean> {
|
async send(input: ChatInput): Promise<boolean> {
|
||||||
if (!this.readyRef) return false;
|
const recovering = this.statusValue === "error";
|
||||||
|
if (!this.readyRef && !recovering) return false;
|
||||||
this.consumeAbort?.abort();
|
this.consumeAbort?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
this.consumeAbort = controller;
|
this.consumeAbort = controller;
|
||||||
this.setError(null);
|
this.setError(null);
|
||||||
this.setStatus("submitted");
|
this.setStatus("submitted");
|
||||||
|
if (recovering && input.type === "answer") {
|
||||||
|
try {
|
||||||
|
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, { signal: controller.signal });
|
||||||
|
if (this.consumeAbort !== controller) return false;
|
||||||
|
if (!res.ok) throw new Error((await res.text()) || "An error occurred.");
|
||||||
|
const record = await res.json() as { messages?: LhMessage[]; turnInFlight?: boolean; turnError?: string };
|
||||||
|
if (this.consumeAbort !== controller) return false;
|
||||||
|
// No verified record/liveness: retain the draft and never guess
|
||||||
|
// whether a lost acknowledgement meant the input was accepted.
|
||||||
|
if (!Array.isArray(record.messages) || typeof record.turnInFlight !== "boolean") throw new Error("An error occurred.");
|
||||||
|
this.setMessages(record.messages, record.turnError);
|
||||||
|
if (record.turnInFlight) {
|
||||||
|
await this.resumeStream(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (input.type === "answer" && (answeredCallIds(record.messages).has(input.toolCallId) || danglingQuestionOf(record.messages)?.toolCallId !== input.toolCallId)) {
|
||||||
|
// The call already resolved, or no longer owns the dock. Adopt
|
||||||
|
// truth, never POST that answer a second time.
|
||||||
|
this.setStatus("ready");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (this.consumeAbort !== controller || controller.signal.aborted) return false;
|
||||||
|
this.setError((err as Error).message);
|
||||||
|
this.setStatus("error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.type !== "continue") this.automaticContinuationBlocked = false;
|
||||||
// The optimistic appends — exactly the messages the server's
|
// The optimistic appends — exactly the messages the server's
|
||||||
// write-ahead will persist (inputAppends), with LOCAL ids so
|
// write-ahead will persist (inputAppends), with LOCAL ids so
|
||||||
// adoption can tell them from the record's absorbed copies.
|
// adoption can tell them from the record's absorbed copies.
|
||||||
@@ -585,7 +689,9 @@ export class SessionModel {
|
|||||||
optimistic.push({ ...newNextMessage(), id: localId() });
|
optimistic.push({ ...newNextMessage(), id: localId() });
|
||||||
} else if (input.type === "answer") {
|
} else if (input.type === "answer") {
|
||||||
const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz";
|
const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz";
|
||||||
optimistic.push({ ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() });
|
const result = { ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() };
|
||||||
|
optimistic.push(result);
|
||||||
|
this.unconfirmedAnswers.set(result.id, input.toolCallId);
|
||||||
}
|
}
|
||||||
if (optimistic.length > 0) this.mutate(() => {
|
if (optimistic.length > 0) this.mutate(() => {
|
||||||
this.tail = [...this.tail, ...optimistic];
|
this.tail = [...this.tail, ...optimistic];
|
||||||
@@ -606,9 +712,7 @@ export class SessionModel {
|
|||||||
// connection is Diet A's whole job.
|
// connection is Diet A's whole job.
|
||||||
const res = await this.postInputEvent(input);
|
const res = await this.postInputEvent(input);
|
||||||
if (res.status === 409) {
|
if (res.status === 409) {
|
||||||
this.setError("A turn is already running — try again in a moment.");
|
throw new Error("A turn is already running — try again in a moment.");
|
||||||
this.setStatus("error");
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// Refusal envelopes (503 BYOK/key gates) and 400s: the RAW body
|
// Refusal envelopes (503 BYOK/key gates) and 400s: the RAW body
|
||||||
@@ -632,7 +736,17 @@ export class SessionModel {
|
|||||||
await this.resumeStream(true);
|
await this.resumeStream(true);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error).name === "AbortError") return accepted; // stop() or a newer consume won
|
if (this.consumeAbort !== controller || controller.signal.aborted) return accepted;
|
||||||
|
// Restore the question when acceptance failed or is unknown. Remove
|
||||||
|
// only THIS optimistic answer, never saved copies/newer live content.
|
||||||
|
// Other input kinds retain their existing optimistic presentation.
|
||||||
|
if (input.type === "answer") {
|
||||||
|
const ids = new Set(optimistic.map(m => m.id));
|
||||||
|
this.mutate(() => {
|
||||||
|
this.tail = this.tail.filter(m => !ids.has(m.id));
|
||||||
|
for (const id of ids) this.unconfirmedAnswers.delete(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
this.setError((err as Error).message);
|
this.setError((err as Error).message);
|
||||||
this.setStatus("error");
|
this.setStatus("error");
|
||||||
// true only once the POST ack landed. A throw before that (the
|
// true only once the POST ack landed. A throw before that (the
|
||||||
@@ -747,10 +861,32 @@ export class SessionModel {
|
|||||||
this.setStatus("ready");
|
this.setStatus("ready");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Submission snapshots outlive the dock, not the session mount. */
|
||||||
|
questionDraft(toolCallId: string): QuestionDraft | undefined {
|
||||||
|
return this.questionDrafts.get(toolCallId);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveQuestionDraft(toolCallId: string, draft: Partial<QuestionDraft>): void {
|
||||||
|
this.questionDrafts.set(toolCallId, { ...this.questionDrafts.get(toolCallId), ...draft });
|
||||||
|
}
|
||||||
|
|
||||||
|
private forgetResolvedDrafts(record: readonly LhMessage[]): void {
|
||||||
|
const answered = answeredCallIds(record);
|
||||||
|
for (const id of answered) this.questionDrafts.delete(id);
|
||||||
|
for (const [id, callId] of this.unconfirmedAnswers) {
|
||||||
|
if (answered.has(callId)) this.unconfirmedAnswers.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Full snapshot swap (the idle settle sync — server canon). */
|
/** Full snapshot swap (the idle settle sync — server canon). */
|
||||||
setMessages(record: LhMessage[]): void {
|
setMessages(record: LhMessage[], turnError?: string): void {
|
||||||
this.mutate(() => {
|
this.mutate(() => {
|
||||||
this.base = record;
|
this.base = record;
|
||||||
|
if (turnError !== undefined) {
|
||||||
|
this.errorValue = { message: turnError };
|
||||||
|
this.automaticContinuationBlocked = true;
|
||||||
|
}
|
||||||
|
this.forgetResolvedDrafts(record);
|
||||||
this.incompleteUsageIds.clear();
|
this.incompleteUsageIds.clear();
|
||||||
this.tail = [];
|
this.tail = [];
|
||||||
this.streamingMessageId = null; // the tail (and its slot) is gone
|
this.streamingMessageId = null; // the tail (and its slot) is gone
|
||||||
@@ -770,10 +906,19 @@ export class SessionModel {
|
|||||||
if (baseAbsorbed) return; // not a pure append — the idle full sync is the net
|
if (baseAbsorbed) return; // not a pure append — the idle full sync is the net
|
||||||
this.mutate(() => {
|
this.mutate(() => {
|
||||||
this.base = record;
|
this.base = record;
|
||||||
|
this.forgetResolvedDrafts(record);
|
||||||
this.tail = this.tail.filter((m) => !absorbed(m, record));
|
this.tail = this.tail.filter((m) => !absorbed(m, record));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Automatic mount/sync continuation is never permission to retry a
|
||||||
|
* failed turn. Read current model truth, not a stale React closure. */
|
||||||
|
async continueIfOwed(record: readonly LhMessage[]): Promise<void> {
|
||||||
|
if (this.statusValue === "ready" && !this.automaticContinuationBlocked && this.getState().error === null && needsContinuation(record)) {
|
||||||
|
await this.send({ type: "continue" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The mount/sync continuation fold (native-messages.ts). */
|
/** The mount/sync continuation fold (native-messages.ts). */
|
||||||
static needsContinuation = needsContinuation;
|
static needsContinuation = needsContinuation;
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { fitGraphView, fitZoomFor, zoomedGraphView, type Viewport } from "@/comp
|
|||||||
import { renderMermaid } from "@/components/mermaid-render";
|
import { renderMermaid } from "@/components/mermaid-render";
|
||||||
import { MermaidError } from "@/components/mermaid-error";
|
import { MermaidError } from "@/components/mermaid-error";
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
|
import { SvgImage } from "@/components/artifacts/svg-image";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SESSION-SCOPED OVERLAYS (owner wave, 2026-09-19): the "fullscreen"
|
* SESSION-SCOPED OVERLAYS (owner wave, 2026-09-19): the "fullscreen"
|
||||||
@@ -269,22 +270,24 @@ export function MermaidOverlay({
|
|||||||
: "transform 180ms ease-out, opacity 160ms ease-out",
|
: "transform 180ms ease-out, opacity 160ms ease-out",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* data-lh-overlay-chart: globals.css pins the svg to the
|
{/* Figures share the card's image boundary and white backing.
|
||||||
wrapper's width (kills mermaid's inline max-width cap)
|
Mermaid keeps its inline renderer; globals.css removes
|
||||||
so the wrapper IS the natural-size box the fit math
|
its max-width cap so it fills the natural-size box. */}
|
||||||
centers. FIGURE MODE: a white stage (the figure's
|
{rawSvg !== undefined ? (
|
||||||
backing law — models draw for a white page) with the
|
<div
|
||||||
caller's invert filter riding it, exactly like the
|
data-lh-overlay-chart
|
||||||
in-card holder. */}
|
className="bg-white"
|
||||||
<div
|
style={inverted ? { filter: "invert(1) hue-rotate(180deg)" } : undefined}
|
||||||
data-lh-overlay-chart
|
>
|
||||||
className={cn(
|
<SvgImage svg={svg} className="block h-auto w-full" />
|
||||||
"[&_svg]:h-auto [&_svg]:w-full",
|
</div>
|
||||||
rawSvg !== undefined && "bg-white",
|
) : (
|
||||||
)}
|
<div
|
||||||
style={rawSvg !== undefined && inverted ? { filter: "invert(1) hue-rotate(180deg)" } : undefined}
|
data-lh-overlay-chart
|
||||||
dangerouslySetInnerHTML={{ __html: svg }}
|
className="[&_svg]:h-auto [&_svg]:w-full"
|
||||||
/>
|
dangerouslySetInnerHTML={{ __html: svg }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
|
||||||
<span className="text-xs text-muted-foreground/60">Drag to pan · scroll to zoom</span>
|
<span className="text-xs text-muted-foreground/60">Drag to pan · scroll to zoom</span>
|
||||||
|
|||||||
+11
-3
@@ -22,16 +22,18 @@ export interface UseChatSessionOptions {
|
|||||||
chatId: string;
|
chatId: string;
|
||||||
/** The native record seed (server-rendered page or a full sync). */
|
/** The native record seed (server-rendered page or a full sync). */
|
||||||
initialMessages: LhMessage[];
|
initialMessages: LhMessage[];
|
||||||
|
/** Transient settlement failure retained by the server, not the transcript. */
|
||||||
|
initialError?: string;
|
||||||
/** Reattach on mount: a turn is running server-side with no live view. */
|
/** Reattach on mount: a turn is running server-side with no live view. */
|
||||||
initiallyInFlight?: boolean;
|
initiallyInFlight?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChatSession({ chatId, initialMessages, initiallyInFlight }: UseChatSessionOptions) {
|
export function useChatSession({ chatId, initialMessages, initialError, initiallyInFlight }: UseChatSessionOptions) {
|
||||||
// The model is per-mount (chatId-keyed component); onChange forces the
|
// The model is per-mount (chatId-keyed component); onChange forces the
|
||||||
// re-render. The ref pattern keeps it stable across renders.
|
// re-render. The ref pattern keeps it stable across renders.
|
||||||
const modelRef = useRef<SessionModel | null>(null);
|
const modelRef = useRef<SessionModel | null>(null);
|
||||||
if (modelRef.current === null) {
|
if (modelRef.current === null) {
|
||||||
modelRef.current = new SessionModel({ chatId, initialMessages });
|
modelRef.current = new SessionModel({ chatId, initialMessages, initialError });
|
||||||
}
|
}
|
||||||
const model = modelRef.current;
|
const model = modelRef.current;
|
||||||
// The render VERSION — bumped by every model change, so the state memo
|
// The render VERSION — bumped by every model change, so the state memo
|
||||||
@@ -101,6 +103,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
|||||||
);
|
);
|
||||||
|
|
||||||
const send = useCallback((input: ChatInput) => model.send(input), [model]);
|
const send = useCallback((input: ChatInput) => model.send(input), [model]);
|
||||||
|
const continueIfOwed = useCallback((record: readonly LhMessage[]) => void model.continueIfOwed(record), [model]);
|
||||||
const steer = useCallback((text: string) => void model.steer(text), [model]);
|
const steer = useCallback((text: string) => void model.steer(text), [model]);
|
||||||
const submitAnswer = useCallback(
|
const submitAnswer = useCallback(
|
||||||
(tool: string, toolCallId: string, output: QuestionAnswerData) =>
|
(tool: string, toolCallId: string, output: QuestionAnswerData) =>
|
||||||
@@ -115,7 +118,9 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
|||||||
(problemCallId: string) => void model.dismissProblem(problemCallId),
|
(problemCallId: string) => void model.dismissProblem(problemCallId),
|
||||||
[model],
|
[model],
|
||||||
);
|
);
|
||||||
const setMessages = useCallback((record: LhMessage[]) => model.setMessages(record), [model]);
|
const questionDraft = useCallback((toolCallId: string) => model.questionDraft(toolCallId), [model]);
|
||||||
|
const saveQuestionDraft = useCallback((toolCallId: string, draft: Parameters<SessionModel["saveQuestionDraft"]>[1]) => model.saveQuestionDraft(toolCallId, draft), [model]);
|
||||||
|
const setMessages = useCallback((record: LhMessage[], turnError?: string) => model.setMessages(record, turnError), [model]);
|
||||||
const adoptRecord = useCallback((record: LhMessage[]) => model.adoptRecord(record), [model]);
|
const adoptRecord = useCallback((record: LhMessage[]) => model.adoptRecord(record), [model]);
|
||||||
const stop = useCallback(() => model.stop(), [model]);
|
const stop = useCallback(() => model.stop(), [model]);
|
||||||
const clearError = useCallback(() => model.clearError(), [model]);
|
const clearError = useCallback(() => model.clearError(), [model]);
|
||||||
@@ -129,10 +134,13 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
|||||||
error: state.error,
|
error: state.error,
|
||||||
steerQueue: state.steerQueue,
|
steerQueue: state.steerQueue,
|
||||||
send,
|
send,
|
||||||
|
continueIfOwed,
|
||||||
steer,
|
steer,
|
||||||
submitAnswer,
|
submitAnswer,
|
||||||
dismissQuestion,
|
dismissQuestion,
|
||||||
dismissProblem,
|
dismissProblem,
|
||||||
|
questionDraft,
|
||||||
|
saveQuestionDraft,
|
||||||
setMessages,
|
setMessages,
|
||||||
adoptRecord,
|
adoptRecord,
|
||||||
stop,
|
stop,
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { MenuShell } from "@/components/menu-shell";
|
import { MenuShell } from "@/components/menu-shell";
|
||||||
import { useSettingsSaved } from "@/components/settings-signal";
|
import { useSettingsSaved } from "@/components/settings-signal";
|
||||||
import { defaultOrderLevel, populateLevel } from "@/components/thinking-default";
|
import { defaultOrderLevel, populateLevel } from "@/components/thinking-default";
|
||||||
|
import { useModelCatalog } from "@/components/model-catalog";
|
||||||
|
import { Hint } from "@/components/ui/hint";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import {
|
||||||
COMPACTION_THRESHOLD_FLOOR,
|
COMPACTION_THRESHOLD_FLOOR,
|
||||||
@@ -63,7 +65,6 @@ import {
|
|||||||
type AgentSettings,
|
type AgentSettings,
|
||||||
type CompactionSettings,
|
type CompactionSettings,
|
||||||
type GraphSettings,
|
type GraphSettings,
|
||||||
type ModelCatalogEntry,
|
|
||||||
type ModelSlotName,
|
type ModelSlotName,
|
||||||
type ModelsSettings,
|
type ModelsSettings,
|
||||||
} from "@/src/core";
|
} from "@/src/core";
|
||||||
@@ -101,15 +102,6 @@ interface SegmentInfo {
|
|||||||
shared: boolean;
|
shared: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The GET /api/models response fields this section consumes. */
|
|
||||||
interface ModelsBody {
|
|
||||||
models: ModelCatalogEntry[];
|
|
||||||
/** The three tiers' RESOLVED pins (BYOK: NULL per unset tier — no
|
|
||||||
* factory floor) — machinery only (the thinking list fallback); the
|
|
||||||
* rows' displays read the record override, never a phantom pin. */
|
|
||||||
slots: Record<ModelSlotName, { model: { provider: string; id: string }; thinkingLevel?: string } | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compose: one tool toggled, the rest of the section carried verbatim.
|
/** Compose: one tool toggled, the rest of the section carried verbatim.
|
||||||
* Pure — exported for tests. */
|
* Pure — exported for tests. */
|
||||||
export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings {
|
export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings {
|
||||||
@@ -234,8 +226,10 @@ function MenuTrigger({
|
|||||||
onClick,
|
onClick,
|
||||||
widthClass,
|
widthClass,
|
||||||
disabled,
|
disabled,
|
||||||
|
hint,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
|
hint?: string;
|
||||||
/** What the trigger shows when there is no override ("Default"). */
|
/** What the trigger shows when there is no override ("Default"). */
|
||||||
emptyLabel: string;
|
emptyLabel: string;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
@@ -250,7 +244,7 @@ function MenuTrigger({
|
|||||||
/** The no-key search state grays the whole row; the trigger follows. */
|
/** The no-key search state grays the whole row; the trigger follows. */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
const button = (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
@@ -269,6 +263,7 @@ function MenuTrigger({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
return hint ? <Hint label={hint}>{button}</Hint> : button;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The section's dropdowns all mount the shared MenuShell (fix wave chunk
|
/* The section's dropdowns all mount the shared MenuShell (fix wave chunk
|
||||||
@@ -303,10 +298,9 @@ export function AgentSection({
|
|||||||
/** Writes the whole compaction section (replace-PUT discipline). */
|
/** Writes the whole compaction section (replace-PUT discipline). */
|
||||||
onCompactionChange: (next: CompactionSettings) => void;
|
onCompactionChange: (next: CompactionSettings) => void;
|
||||||
}) {
|
}) {
|
||||||
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
|
const { catalog: body, failed: catalogFailed, refresh: refreshCatalog } = useModelCatalog();
|
||||||
/** The three tiers' resolved pins — GET /api/models `slots`. The rows'
|
const catalog = body?.models;
|
||||||
* Default hints and thinking lists resolve from it. */
|
const slots = body?.slots;
|
||||||
const [slots, setSlots] = useState<ModelsBody["slots"] | null>(null);
|
|
||||||
/** The live search state (/api/search) — the row's reason line. */
|
/** The live search state (/api/search) — the row's reason line. */
|
||||||
const [search, setSearch] = useState<{ available: boolean; engine: string | null; reason: string } | null>(null);
|
const [search, setSearch] = useState<{ available: boolean; engine: string | null; reason: string } | null>(null);
|
||||||
/** Which search engines have a key (/api/keys) — the no-keys-anywhere
|
/** Which search engines have a key (/api/keys) — the no-keys-anywhere
|
||||||
@@ -341,28 +335,9 @@ export function AgentSection({
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// The catalog (federated: keyed providers only), the resolved slots,
|
// The shared catalog seeds the first frame and refreshes per mount and
|
||||||
// the prompt segments, and the search engines' key presence — one
|
// keys/models signal. Settings props still own saved selections/levels.
|
||||||
// fetch each per section mount. The models read ALSO re-runs on the
|
|
||||||
// cross-surface signal (fix wave: another surface's write re-reads
|
|
||||||
// into the slot rows live).
|
|
||||||
const modelsRequest = useRef(0);
|
|
||||||
const modelsMounted = useRef(false);
|
|
||||||
const readModels = useCallback((connectionChanged = false) => {
|
|
||||||
const request = ++modelsRequest.current;
|
|
||||||
if (connectionChanged) setCatalog(null);
|
|
||||||
fetch("/api/models")
|
|
||||||
.then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); })
|
|
||||||
.then((body: ModelsBody) => {
|
|
||||||
if (!modelsMounted.current || request !== modelsRequest.current) return;
|
|
||||||
setCatalog(body.models);
|
|
||||||
setSlots(body.slots);
|
|
||||||
})
|
|
||||||
.catch(() => { if (modelsMounted.current && request === modelsRequest.current) setCatalog(null); });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useSettingsSaved((section) => {
|
useSettingsSaved((section) => {
|
||||||
if (section === "models" || section === "keys") readModels(section === "keys");
|
|
||||||
// THE state-echo root cause: the prompt switches write through the
|
// THE state-echo root cause: the prompt switches write through the
|
||||||
// agent section, but this view (the toggles' truth) never re-read —
|
// agent section, but this view (the toggles' truth) never re-read —
|
||||||
// the settings-saved law (every reader re-reads) was missing this
|
// the settings-saved law (every reader re-reads) was missing this
|
||||||
@@ -371,8 +346,6 @@ export function AgentSection({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
modelsMounted.current = true;
|
|
||||||
readModels();
|
|
||||||
fetch("/api/settings/prompts")
|
fetch("/api/settings/prompts")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body))
|
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body))
|
||||||
@@ -388,7 +361,6 @@ export function AgentSection({
|
|||||||
setEngineKeys(present);
|
setEngineKeys(present);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => { modelsMounted.current = false; modelsRequest.current++; };
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// The reason line re-reads /api/search AFTER an engine write lands —
|
// The reason line re-reads /api/search AFTER an engine write lands —
|
||||||
@@ -615,6 +587,7 @@ export function AgentSection({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsGroup label="Models" testid="group-models">
|
<SettingsGroup label="Models" testid="group-models">
|
||||||
|
{catalogFailed && <p role="alert" className="text-xs text-red-400">Model catalog could not be loaded. Check the connection and retry. <button type="button" className="underline" onClick={refreshCatalog}>Retry</button></p>}
|
||||||
{MODEL_SLOTS.map((slot) => {
|
{MODEL_SLOTS.map((slot) => {
|
||||||
const override = models?.[slot];
|
const override = models?.[slot];
|
||||||
const modelId = override?.model?.id ?? "";
|
const modelId = override?.model?.id ?? "";
|
||||||
@@ -652,6 +625,7 @@ export function AgentSection({
|
|||||||
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
|
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
|
||||||
<MenuTrigger
|
<MenuTrigger
|
||||||
value={modelId ? prettyModel(modelId) : ""}
|
value={modelId ? prettyModel(modelId) : ""}
|
||||||
|
hint={modelId ? `${provider}:${modelId}` : undefined}
|
||||||
emptyLabel="—"
|
emptyLabel="—"
|
||||||
ariaLabel={`${SLOT_META[slot].label} model`}
|
ariaLabel={`${SLOT_META[slot].label} model`}
|
||||||
testid={`slot-model-${slot}`}
|
testid={`slot-model-${slot}`}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import type { ChatGPTStatus } from "@/src/core";
|
|||||||
import { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls";
|
import { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Toggle } from "./controls";
|
import { Toggle } from "./controls";
|
||||||
|
import { useChatGPTBridge } from "./subscription-client";
|
||||||
|
|
||||||
export const CHATGPT_NOTICE = "LearnHarness stores your ChatGPT account details and credentials in an unencrypted local file on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness.";
|
export const CHATGPT_NOTICE = "LearnHarness stores your ChatGPT account details and credentials in an unencrypted local file on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness.";
|
||||||
|
export function chatGPTNotice(web = false): string { return web ? `${CHATGPT_NOTICE.replace("on this device", "on the machine running LearnHarness")} Complete ChatGPT sign-in in a browser on that same machine.` : CHATGPT_NOTICE; }
|
||||||
export const CHATGPT_BACKGROUND_HINT = "Background work counts toward your ChatGPT plan usage.";
|
export const CHATGPT_BACKGROUND_HINT = "Background work counts toward your ChatGPT plan usage.";
|
||||||
const BACKGROUND_LABEL = "Allow background use for all ChatGPT accounts";
|
const BACKGROUND_LABEL = "Allow background use for all ChatGPT accounts";
|
||||||
const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed.";
|
const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed.";
|
||||||
@@ -41,6 +43,8 @@ const Context = createContext({
|
|||||||
/** UI lifetime spans setup's keys → models transition. Only existing safe controls
|
/** UI lifetime spans setup's keys → models transition. Only existing safe controls
|
||||||
* are used; account IDs remain control values, never displayed account labels. */
|
* are used; account IDs remain control values, never displayed account labels. */
|
||||||
export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const bridge = useChatGPTBridge();
|
||||||
|
const web = typeof window !== "undefined" && bridge !== window.lh?.chatgpt;
|
||||||
const [status, setStatus] = useState<ChatGPTStatus>(empty);
|
const [status, setStatus] = useState<ChatGPTStatus>(empty);
|
||||||
const [dialog, setDialog] = useState<Dialog | null>(null);
|
const [dialog, setDialog] = useState<Dialog | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -79,14 +83,14 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background) {
|
if (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background) {
|
||||||
operation.current = "change";
|
operation.current = "change";
|
||||||
const request = ++statusSequence.current;
|
const request = ++statusSequence.current;
|
||||||
const result: unknown = await window.lh!.chatgpt!.consent(attempt.background);
|
const result: unknown = await bridge!.consent(attempt.background);
|
||||||
if (!owns(attempt)) return;
|
if (!owns(attempt)) return;
|
||||||
if (!isStatus(result)) { setError(replyError(result)); finish(attempt); return; }
|
if (!isStatus(result)) { setError(replyError(result)); finish(attempt); return; }
|
||||||
replyFailure = result.error;
|
replyFailure = result.error;
|
||||||
let saved: ChatGPTStatus | null;
|
let saved: ChatGPTStatus | null;
|
||||||
if (request !== statusSequence.current) {
|
if (request !== statusSequence.current) {
|
||||||
const refresh = ++statusSequence.current;
|
const refresh = ++statusSequence.current;
|
||||||
const value: unknown = await window.lh!.chatgpt!.status();
|
const value: unknown = await bridge!.status();
|
||||||
if (!owns(attempt)) return;
|
if (!owns(attempt)) return;
|
||||||
saved = refresh === statusSequence.current ? publishStatus(value) : statusRef.current;
|
saved = refresh === statusSequence.current ? publishStatus(value) : statusRef.current;
|
||||||
} else saved = publishStatus(result);
|
} else saved = publishStatus(result);
|
||||||
@@ -120,7 +124,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mounted.current = true;
|
mounted.current = true;
|
||||||
const bridge = window.lh?.chatgpt;
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
const request = ++statusSequence.current;
|
const request = ++statusSequence.current;
|
||||||
try {
|
try {
|
||||||
@@ -131,7 +134,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
|||||||
void refresh();
|
void refresh();
|
||||||
const off = bridge?.onChange(() => void refresh());
|
const off = bridge?.onChange(() => void refresh());
|
||||||
return () => { mounted.current = false; generation.current++; loginAttempt.current = null; off?.(); };
|
return () => { mounted.current = false; generation.current++; loginAttempt.current = null; off?.(); };
|
||||||
}, []);
|
}, [bridge]);
|
||||||
|
|
||||||
const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId) ?? dialog.account : undefined;
|
const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId) ?? dialog.account : undefined;
|
||||||
const managedPresent = !!managed && status.accounts.some((a) => a.id === managed.id);
|
const managedPresent = !!managed && status.accounts.some((a) => a.id === managed.id);
|
||||||
@@ -161,13 +164,13 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// Keep cancellation serialized even after login's pending IPC reply.
|
// Keep cancellation serialized even after login's pending IPC reply.
|
||||||
operation.current = "cancel"; setBusy(true);
|
operation.current = "cancel"; setBusy(true);
|
||||||
const request = ++statusSequence.current;
|
const request = ++statusSequence.current;
|
||||||
void window.lh?.chatgpt?.cancel().then(async (value: unknown) => {
|
void bridge?.cancel().then(async (value: unknown) => {
|
||||||
if (!mounted.current || generation.current !== intent) return;
|
if (!mounted.current || generation.current !== intent) return;
|
||||||
if (!isStatus(value)) { setError(replyError(value)); return; }
|
if (!isStatus(value)) { setError(replyError(value)); return; }
|
||||||
let next: ChatGPTStatus | null;
|
let next: ChatGPTStatus | null;
|
||||||
if (request !== statusSequence.current) {
|
if (request !== statusSequence.current) {
|
||||||
const refresh = ++statusSequence.current;
|
const refresh = ++statusSequence.current;
|
||||||
const current: unknown = await window.lh!.chatgpt!.status();
|
const current: unknown = await bridge!.status();
|
||||||
if (!mounted.current || generation.current !== intent) return;
|
if (!mounted.current || generation.current !== intent) return;
|
||||||
next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current;
|
next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current;
|
||||||
} else next = publishStatus(value);
|
} else next = publishStatus(value);
|
||||||
@@ -195,7 +198,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
|||||||
let next: ChatGPTStatus | null;
|
let next: ChatGPTStatus | null;
|
||||||
if (request !== statusSequence.current) {
|
if (request !== statusSequence.current) {
|
||||||
const refresh = ++statusSequence.current;
|
const refresh = ++statusSequence.current;
|
||||||
const current: unknown = await window.lh!.chatgpt!.status();
|
const current: unknown = await bridge!.status();
|
||||||
if (!mounted.current || intent !== generation.current) return;
|
if (!mounted.current || intent !== generation.current) return;
|
||||||
next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current;
|
next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current;
|
||||||
} else next = publishStatus(value);
|
} else next = publishStatus(value);
|
||||||
@@ -214,7 +217,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
|||||||
finally { if (intent === generation.current) { operation.current = null; if (mounted.current) setBusy(false); } }
|
finally { if (intent === generation.current) { operation.current = null; if (mounted.current) setBusy(false); } }
|
||||||
}
|
}
|
||||||
async function login() {
|
async function login() {
|
||||||
const bridge = window.lh?.chatgpt;
|
|
||||||
const owner = dialogRef.current;
|
const owner = dialogRef.current;
|
||||||
if (!bridge || operation.current || !status.available || status.pending || owner?.kind !== "connect") return;
|
if (!bridge || operation.current || !status.available || status.pending || owner?.kind !== "connect") return;
|
||||||
const attempt: LoginAttempt = { generation: ++generation.current, dialog: owner, registration, background, backgroundDirty, pending: false, phase: "starting" };
|
const attempt: LoginAttempt = { generation: ++generation.current, dialog: owner, registration, background, backgroundDirty, pending: false, phase: "starting" };
|
||||||
@@ -241,7 +243,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// No finally: pending OAuth outlives this IPC promise. Only its status
|
// No finally: pending OAuth outlives this IPC promise. Only its status
|
||||||
// completion, failure, or explicit cancellation releases the attempt.
|
// completion, failure, or explicit cancellation releases the attempt.
|
||||||
}
|
}
|
||||||
const bridge = typeof window !== "undefined" ? window.lh?.chatgpt : undefined;
|
|
||||||
const context = {
|
const context = {
|
||||||
status, busy, error: warning ? null : visibleError, inlineError: dialog || warning ? null : visibleError, warning: dialog?.kind === "warning" ? null : warning,
|
status, busy, error: warning ? null : visibleError, inlineError: dialog || warning ? null : visibleError, warning: dialog?.kind === "warning" ? null : warning,
|
||||||
connect: openConnect,
|
connect: openConnect,
|
||||||
@@ -262,7 +263,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
|||||||
</> : dialog.kind === "manage" && dialog.confirm ? <div data-testid="chatgpt-disconnect-confirmation">
|
</> : dialog.kind === "manage" && dialog.confirm ? <div data-testid="chatgpt-disconnect-confirmation">
|
||||||
<p className="mt-3 text-sm font-semibold text-red-400">{CHATGPT_DISCONNECT_COPY.title}</p>
|
<p className="mt-3 text-sm font-semibold text-red-400">{CHATGPT_DISCONNECT_COPY.title}</p>
|
||||||
<p className="mt-3 text-xs text-foreground">{managed?.email || "ChatGPT account"}</p>
|
<p className="mt-3 text-xs text-foreground">{managed?.email || "ChatGPT account"}</p>
|
||||||
<p className="mt-3 text-xs text-muted-foreground">{CHATGPT_DISCONNECT_COPY.body}</p>
|
<p className="mt-3 text-xs text-muted-foreground">{web ? CHATGPT_DISCONNECT_COPY.body.replace("from this device", "from the machine running LearnHarness") : CHATGPT_DISCONNECT_COPY.body}</p>
|
||||||
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
|
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
|
||||||
<div className="mt-4 flex items-center justify-end gap-2">
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
<Button variant="ghost" size="sm" disabled={busy} onClick={close}>Cancel</Button>
|
<Button variant="ghost" size="sm" disabled={busy} onClick={close}>Cancel</Button>
|
||||||
@@ -277,7 +278,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
|||||||
</select>
|
</select>
|
||||||
{registration && <Button variant="ghost" size="xs" disabled={busy} onClick={() => context.manage(registration)}>Manage…</Button>}
|
{registration && <Button variant="ghost" size="xs" disabled={busy} onClick={() => context.manage(registration)}>Manage…</Button>}
|
||||||
</div>}
|
</div>}
|
||||||
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p>
|
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{chatGPTNotice(web)}</p>
|
||||||
</> : null}
|
</> : null}
|
||||||
<div className={`mt-4 flex items-center gap-3 text-xs text-foreground ${busy && operation.current === "change" && status.available && !status.pending ? "[&>button:disabled]:cursor-auto" : ""}`} data-testid="chatgpt-global-background">
|
<div className={`mt-4 flex items-center gap-3 text-xs text-foreground ${busy && operation.current === "change" && status.available && !status.pending ? "[&>button:disabled]:cursor-auto" : ""}`} data-testid="chatgpt-global-background">
|
||||||
<Toggle label={BACKGROUND_LABEL} checked={dialog.kind === "connect" ? background : status.allowBackgroundUse} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
|
<Toggle label={BACKGROUND_LABEL} checked={dialog.kind === "connect" ? background : status.allowBackgroundUse} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function SettingsRow({
|
|||||||
dimmed,
|
dimmed,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
hint?: string;
|
hint?: ReactNode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
testid?: string;
|
testid?: string;
|
||||||
/** Dependency graying, graph-view style: the whole row dims when the
|
/** Dependency graying, graph-view style: the whole row dims when the
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ export function classifyKeys(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A key row names its credential, not whichever provider sorts first.
|
||||||
|
* Pi exposes Zen and Go separately but both read OPENCODE_API_KEY. */
|
||||||
|
export function providerKeyLabel(id: string, providers: readonly ProviderKeyInfo[] | undefined): string | undefined {
|
||||||
|
const matches = providers?.filter((p) => p.envVar === id);
|
||||||
|
if (matches?.some((p) => p.id === "opencode") && matches.some((p) => p.id === "opencode-go")) return "OpenCode (Zen / Go)";
|
||||||
|
return matches?.[0]?.label;
|
||||||
|
}
|
||||||
|
|
||||||
/** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an
|
/** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an
|
||||||
* environment-owned value — the row itself is read-only then). Pure. */
|
* environment-owned value — the row itself is read-only then). Pure. */
|
||||||
export function keyStatusLine(status: AuthKeyStatus): string {
|
export function keyStatusLine(status: AuthKeyStatus): string {
|
||||||
@@ -417,7 +425,7 @@ export function KeysSection({
|
|||||||
{providerRows.map((status) => (
|
{providerRows.map((status) => (
|
||||||
<KeyTableRow
|
<KeyTableRow
|
||||||
key={status.id}
|
key={status.id}
|
||||||
label={providers?.find((p) => p.envVar === status.id)?.label ?? status.info?.label ?? status.id}
|
label={providerKeyLabel(status.id, providers) ?? status.info?.label ?? status.id}
|
||||||
docsUrl={status.info?.docsUrl}
|
docsUrl={status.info?.docsUrl}
|
||||||
status={status}
|
status={status}
|
||||||
expanded={openRow === status.id}
|
expanded={openRow === status.id}
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ import {
|
|||||||
type UserSettings,
|
type UserSettings,
|
||||||
} from "@/src/core";
|
} from "@/src/core";
|
||||||
import { useAppearance } from "./appearance.tsx";
|
import { useAppearance } from "./appearance.tsx";
|
||||||
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
|
import { SettingsSection } from "./controls.tsx";
|
||||||
|
import { UpdateSettings } from "./update-settings.tsx";
|
||||||
import { AgentSection } from "./agent-section.tsx";
|
import { AgentSection } from "./agent-section.tsx";
|
||||||
import type { ModelsSettings } from "@/src/core";
|
import type { ModelsSettings } from "@/src/core";
|
||||||
import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "@/components/menu-shell";
|
import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "@/components/menu-shell";
|
||||||
@@ -75,10 +76,6 @@ const DialogContext = createContext<{ open: (section?: string, row?: string) =>
|
|||||||
/** AppShell-level mount: the sidebar row and the mod+, chord call this.
|
/** AppShell-level mount: the sidebar row and the mod+, chord call this.
|
||||||
* `open(section)` lands the dialog on a specific section (the first-run
|
* `open(section)` lands the dialog on a specific section (the first-run
|
||||||
* setup flow deep-links to Keys when no provider key exists yet). */
|
* setup flow deep-links to Keys when no provider key exists yet). */
|
||||||
/** The auto-update row's hint (tooltip wave): honest user language — no
|
|
||||||
* stream vocabulary. Exported for the copy pin (tooltip-mechanism test). */
|
|
||||||
export const AUTO_UPDATE_HINT = "Automatic updates arrive in an upcoming release";
|
|
||||||
|
|
||||||
export function SettingsDialogProvider({
|
export function SettingsDialogProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -92,6 +89,7 @@ export function SettingsDialogProvider({
|
|||||||
setLandOnRow(row ?? null);
|
setLandOnRow(row ?? null);
|
||||||
setShow(true);
|
setShow(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
useEffect(() => window.lh?.onOpenSettings?.((section) => open(section)), [open]);
|
||||||
return (
|
return (
|
||||||
<ChatGPTUIProvider><NativeSubscriptionUIProvider><DialogContext.Provider value={{ open }}>
|
<ChatGPTUIProvider><NativeSubscriptionUIProvider><DialogContext.Provider value={{ open }}>
|
||||||
{children}
|
{children}
|
||||||
@@ -362,19 +360,12 @@ export function SettingsDialogBody({
|
|||||||
title="General"
|
title="General"
|
||||||
testid="section-general"
|
testid="section-general"
|
||||||
>
|
>
|
||||||
<SettingsRow
|
<UpdateSettings
|
||||||
label="Auto-update"
|
autoUpdate={record.general?.autoUpdate ?? true}
|
||||||
hint={AUTO_UPDATE_HINT}
|
onAutoUpdateChange={(on) =>
|
||||||
testid="row-auto-update"
|
void write("general", { ...record.general, autoUpdate: on })
|
||||||
>
|
}
|
||||||
<Toggle
|
/>
|
||||||
checked={record.general?.autoUpdate ?? true}
|
|
||||||
label="auto update"
|
|
||||||
onChange={(on) =>
|
|
||||||
void write("general", { autoUpdate: on })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
{active === "keys" && (
|
{active === "keys" && (
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import type { ChatGPTControlBridge, ChatGPTStatus, NativeSubscriptionControlBridge, NativeSubscriptionProviderId, NativeSubscriptionStatus } from "@/src/core";
|
||||||
|
import { useNotifyHub, type createNotifyHub } from "@/components/server-events";
|
||||||
|
|
||||||
|
type Hub = ReturnType<typeof createNotifyHub> | null;
|
||||||
|
type Reply<T> = { value: T; browserAction?: string; error?: string };
|
||||||
|
const PATH = "/api/subscriptions";
|
||||||
|
const headers = { "Content-Type": "application/json", "x-learnharness-subscriptions": "1" };
|
||||||
|
|
||||||
|
async function status() {
|
||||||
|
const response = await fetch(PATH, { headers, cache: "no-store" });
|
||||||
|
if (!response.ok) throw new Error("Subscription status is unavailable.");
|
||||||
|
return response.json() as Promise<{ chatgpt: ChatGPTStatus; native: NativeSubscriptionStatus[] }>;
|
||||||
|
}
|
||||||
|
async function control<T>(providerId: string, op: string, fields: Record<string, unknown> = {}): Promise<Reply<T>> {
|
||||||
|
const response = await fetch(PATH, { method: "POST", headers, body: JSON.stringify({ providerId, op, ...fields }) });
|
||||||
|
if (!response.ok) throw new Error("The subscription connection could not be changed.");
|
||||||
|
return response.json() as Promise<Reply<T>>;
|
||||||
|
}
|
||||||
|
function onChange(hub: Hub, listener: () => void): () => void {
|
||||||
|
const offEvent = hub?.subscribe("subscriptions-changed", listener);
|
||||||
|
const offResync = hub?.onResync(listener);
|
||||||
|
return () => { offEvent?.(); offResync?.(); };
|
||||||
|
}
|
||||||
|
/** Reserve the new tab synchronously in the click gesture. Only a one-use
|
||||||
|
* same-origin action URL enters app JS; provider URLs remain in the owner.
|
||||||
|
* Electron dev has no tabs: its narrow main-process opener performs this step. */
|
||||||
|
async function withBrowser<T>(action: () => Promise<Reply<T>>): Promise<T> {
|
||||||
|
const external = window.lh?.subscriptionTransport === "web" ? window.lh.openSubscriptionBrowser : undefined;
|
||||||
|
const tab = external ? null : window.open("about:blank", "_blank");
|
||||||
|
if (!external && !tab) throw new Error("Allow a sign-in tab to open, then try again.");
|
||||||
|
if (tab) tab.opener = null;
|
||||||
|
try {
|
||||||
|
const reply = await action();
|
||||||
|
if (reply.browserAction) {
|
||||||
|
if (!/^\/api\/subscriptions\/browser\?id=[0-9a-f-]{36}$/.test(reply.browserAction)) throw new Error("Invalid sign-in action.");
|
||||||
|
if (external) await external(reply.browserAction);
|
||||||
|
else if (tab && !tab.closed) tab.location.href = reply.browserAction;
|
||||||
|
else throw new Error("The sign-in tab was closed. Cancel and try again.");
|
||||||
|
} else tab?.close();
|
||||||
|
return reply.value;
|
||||||
|
} catch (error) { tab?.close(); throw error; }
|
||||||
|
}
|
||||||
|
export function createWebChatGPTBridge(hub: Hub): ChatGPTControlBridge {
|
||||||
|
const call = async (op: string, fields?: Record<string, unknown>) => (await control<ChatGPTStatus>("chatgpt", op, fields)).value;
|
||||||
|
return {
|
||||||
|
status: async () => (await status()).chatgpt,
|
||||||
|
login: (id) => withBrowser(() => control<ChatGPTStatus>("chatgpt", "login", { id })),
|
||||||
|
cancel: () => call("cancel"), select: (id) => call("select", { id }),
|
||||||
|
disconnect: (id) => call("disconnect", { id }), logout: (id) => call("logout", { id }),
|
||||||
|
consent: (enabled) => call("consent", { enabled }),
|
||||||
|
onChange: (listener) => onChange(hub, listener),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function createWebNativeBridge(hub: Hub): NativeSubscriptionControlBridge {
|
||||||
|
const call = async (providerId: NativeSubscriptionProviderId, op: string, fields?: Record<string, unknown>) => { await control(providerId, op, fields); };
|
||||||
|
return {
|
||||||
|
status: async () => (await status()).native,
|
||||||
|
login: (providerId, enabled) => call(providerId, "login", { enabled }),
|
||||||
|
cancel: (providerId) => call(providerId, "cancel"),
|
||||||
|
respond: (providerId, attemptId, promptId, value) => call(providerId, "respond", { attemptId, promptId, value }),
|
||||||
|
open: async (providerId, attemptId, actionId) => { await withBrowser(() => control(providerId, "open", { attemptId, actionId })); },
|
||||||
|
retry: (providerId) => call(providerId, "retry"), disconnect: (providerId) => call(providerId, "disconnect"),
|
||||||
|
consent: (providerId, enabled) => call(providerId, "consent", { enabled }),
|
||||||
|
onChange: (listener) => onChange(hub, listener),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function useChatGPTBridge(): ChatGPTControlBridge | undefined {
|
||||||
|
const hub = useNotifyHub();
|
||||||
|
return useMemo(() => typeof window === "undefined" ? undefined : window.lh?.subscriptionTransport !== "web" && window.lh?.chatgpt ? window.lh.chatgpt : createWebChatGPTBridge(hub), [hub]);
|
||||||
|
}
|
||||||
|
export function useNativeSubscriptionBridge(): NativeSubscriptionControlBridge | undefined {
|
||||||
|
const hub = useNotifyHub();
|
||||||
|
return useMemo(() => typeof window === "undefined" ? undefined : window.lh?.subscriptionTransport !== "web" && window.lh?.subscriptions ? window.lh.subscriptions : createWebNativeBridge(hub), [hub]);
|
||||||
|
}
|
||||||
@@ -6,16 +6,19 @@ import { notifySettingsSaved } from "@/components/settings-signal";
|
|||||||
import { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls";
|
import { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls";
|
||||||
import { ChatGPTConnection } from "./chatgpt-connection";
|
import { ChatGPTConnection } from "./chatgpt-connection";
|
||||||
import { Toggle } from "./controls";
|
import { Toggle } from "./controls";
|
||||||
|
import { useNativeSubscriptionBridge } from "./subscription-client";
|
||||||
|
|
||||||
export const COPILOT_POLICY_NOTICE = "Pi's native Copilot sign-in may enable additional models whose policy is not yet configured. Models explicitly disabled by policy are not enabled. Organization restrictions still apply.";
|
export const COPILOT_POLICY_NOTICE = "Pi's native Copilot sign-in may enable additional models whose policy is not yet configured. Models explicitly disabled by policy are not enabled. Organization restrictions still apply.";
|
||||||
export const SUBSCRIPTION_BACKGROUND_HINT = "Allow LearnHarness helper agents and automatic follow-ups to use this connection.";
|
export const SUBSCRIPTION_BACKGROUND_HINT = "Allow LearnHarness helper agents and automatic follow-ups to use this connection.";
|
||||||
export function subscriptionNotice(label: string): string { return `Your prompts, tool results, and relevant session context are sent to ${label} to run the work you select. Credentials are stored locally in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key.`; }
|
export function subscriptionNotice(label: string, web = false): string { return `Your prompts, tool results, and relevant session context are sent to ${label} to run the work you select. Credentials are stored ${web ? "on the machine running LearnHarness" : "locally"} in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key.`; }
|
||||||
export function subscriptionDisconnectCopy(label: string): string { return `Disconnect ${label} from LearnHarness? This stops work using this connection and removes its saved credentials from this device. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them.`; }
|
export function subscriptionDisconnectCopy(label: string, web = false): string { return `Disconnect ${label} from LearnHarness? This stops work using this connection and removes its saved credentials from ${web ? "the machine running LearnHarness" : "this device"}. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them.`; }
|
||||||
export function emptySubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus { return { providerId, available: false, connected: false, pending: false, allowBackgroundUse: true, connectionId: null, epoch: 0, authorityRevision: 0, revision: 0 }; }
|
export function emptySubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus { return { providerId, available: false, connected: false, pending: false, allowBackgroundUse: true, connectionId: null, epoch: 0, authorityRevision: 0, revision: 0 }; }
|
||||||
const Context = createContext({ statuses: [] as NativeSubscriptionStatus[], busy: false, open: (_providerId: NativeSubscriptionProviderId) => {} });
|
const Context = createContext({ statuses: [] as NativeSubscriptionStatus[], busy: false, open: (_providerId: NativeSubscriptionProviderId) => {} });
|
||||||
const CONTROL_ERROR = "The connection could not be changed. Try again.";
|
const CONTROL_ERROR = "The connection could not be changed. Try again.";
|
||||||
|
|
||||||
export function NativeSubscriptionUIProvider({ children }: { children: React.ReactNode }) {
|
export function NativeSubscriptionUIProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const bridge = useNativeSubscriptionBridge();
|
||||||
|
const web = typeof window !== "undefined" && bridge !== window.lh?.subscriptions;
|
||||||
const [statuses, setStatuses] = useState<NativeSubscriptionStatus[]>([]);
|
const [statuses, setStatuses] = useState<NativeSubscriptionStatus[]>([]);
|
||||||
const [dialog, setDialog] = useState<NativeSubscriptionProviderId | null>(null);
|
const [dialog, setDialog] = useState<NativeSubscriptionProviderId | null>(null);
|
||||||
const [view, setView] = useState<"notice" | "connection" | "manage">("notice");
|
const [view, setView] = useState<"notice" | "connection" | "manage">("notice");
|
||||||
@@ -30,7 +33,7 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
|
|||||||
async function refresh() {
|
async function refresh() {
|
||||||
const request = ++sequence.current;
|
const request = ++sequence.current;
|
||||||
try {
|
try {
|
||||||
const values = await window.lh?.subscriptions?.status();
|
const values = await bridge?.status();
|
||||||
if (!mounted.current || request !== sequence.current || !Array.isArray(values)) return;
|
if (!mounted.current || request !== sequence.current || !Array.isArray(values)) return;
|
||||||
const accepted = NATIVE_SUBSCRIPTIONS.map(({ providerId }) => {
|
const accepted = NATIVE_SUBSCRIPTIONS.map(({ providerId }) => {
|
||||||
const next = values.find((s) => s.providerId === providerId), old = statusesRef.current.find((s) => s.providerId === providerId);
|
const next = values.find((s) => s.providerId === providerId), old = statusesRef.current.find((s) => s.providerId === providerId);
|
||||||
@@ -41,10 +44,10 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
|
|||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mounted.current = true; void refresh();
|
mounted.current = true; void refresh();
|
||||||
const off = window.lh?.subscriptions?.onChange(() => { void refresh(); notifySettingsSaved("keys"); });
|
const off = bridge?.onChange(() => { void refresh(); notifySettingsSaved("keys"); });
|
||||||
return () => { mounted.current = false; generation.current++; off?.(); };
|
return () => { mounted.current = false; generation.current++; off?.(); };
|
||||||
// The root-lifetime subscriber reads current state through refs.
|
// The root-lifetime subscriber reads current state through refs.
|
||||||
}, []);
|
}, [bridge]);
|
||||||
useEffect(() => { setPromptValue(""); }, [prompt?.id]);
|
useEffect(() => { setPromptValue(""); }, [prompt?.id]);
|
||||||
function open(providerId: NativeSubscriptionProviderId) {
|
function open(providerId: NativeSubscriptionProviderId) {
|
||||||
if (operation || statusesRef.current.some((s) => s.pending)) return;
|
if (operation || statusesRef.current.some((s) => s.pending)) return;
|
||||||
@@ -62,7 +65,6 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
|
|||||||
function close() {
|
function close() {
|
||||||
if (!dialog || busy) return;
|
if (!dialog || busy) return;
|
||||||
if (confirm) { setConfirm(false); setError(null); return; }
|
if (confirm) { setConfirm(false); setError(null); return; }
|
||||||
const bridge = window.lh?.subscriptions;
|
|
||||||
if ((status?.pending || operation === "login") && bridge) { void control(() => bridge.cancel(dialog), true); return; }
|
if ((status?.pending || operation === "login") && bridge) { void control(() => bridge.cancel(dialog), true); return; }
|
||||||
generation.current++; setDialog(null); setError(null);
|
generation.current++; setDialog(null); setError(null);
|
||||||
}
|
}
|
||||||
@@ -71,23 +73,21 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
|
|||||||
generation.current++; setError(null); setConfirm(false); setBackground(status.allowBackgroundUse); setBackgroundDirty(false); setView("notice");
|
generation.current++; setError(null); setConfirm(false); setBackground(status.allowBackgroundUse); setBackgroundDirty(false); setView("notice");
|
||||||
}
|
}
|
||||||
async function login() {
|
async function login() {
|
||||||
const bridge = window.lh?.subscriptions;
|
|
||||||
if (!dialog || !bridge || operation || !status?.available) return;
|
if (!dialog || !bridge || operation || !status?.available) return;
|
||||||
const owner = ++generation.current; setOperation("login"); setError(null);
|
const owner = ++generation.current; setOperation("login"); setError(null);
|
||||||
try { await bridge.login(dialog, backgroundDirty ? background : undefined); await refresh(); if (mounted.current && generation.current === owner) setDialog(null); }
|
try { await bridge.login(dialog, backgroundDirty ? background : undefined); await refresh(); if (mounted.current && generation.current === owner) setDialog(null); }
|
||||||
catch { if (mounted.current && generation.current === owner) setError("Sign-in did not complete. Try connecting again."); }
|
catch { if (mounted.current && generation.current === owner) setError("Sign-in did not complete. Try connecting again."); }
|
||||||
finally { if (mounted.current && generation.current === owner) setOperation(null); }
|
finally { if (mounted.current && generation.current === owner) setOperation(null); }
|
||||||
}
|
}
|
||||||
const bridge = typeof window === "undefined" ? undefined : window.lh?.subscriptions;
|
|
||||||
return <Context.Provider value={{ statuses, busy: !!operation, open }}>
|
return <Context.Provider value={{ statuses, busy: !!operation, open }}>
|
||||||
{children}
|
{children}
|
||||||
{dialog && descriptor && status && <ConnectionDialog key={`${dialog}:${view}:${confirm}`} label={`${descriptor.label} connection`} testid="subscription-connection-dialog" backdropTestid="subscription-dialog-backdrop" busy={busy} onClose={close}>
|
{dialog && descriptor && status && <ConnectionDialog key={`${dialog}:${view}:${confirm}`} label={`${descriptor.label} connection`} testid="subscription-connection-dialog" backdropTestid="subscription-dialog-backdrop" busy={busy} onClose={close}>
|
||||||
<h2 className="text-sm font-semibold text-foreground">{descriptor.label}</h2>
|
<h2 className="text-sm font-semibold text-foreground">{descriptor.label}</h2>
|
||||||
{confirm ? <>
|
{confirm ? <>
|
||||||
<p className="mt-3 text-sm font-semibold text-red-400">Disconnect {descriptor.label}?</p>
|
<p className="mt-3 text-sm font-semibold text-red-400">Disconnect {descriptor.label}?</p>
|
||||||
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">{subscriptionDisconnectCopy(descriptor.label)}</p>
|
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">{subscriptionDisconnectCopy(descriptor.label, web)}</p>
|
||||||
</> : view === "notice" ? <>
|
</> : view === "notice" ? <>
|
||||||
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="subscription-privacy-notice">{subscriptionNotice(descriptor.label)}</p>
|
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="subscription-privacy-notice">{subscriptionNotice(descriptor.label, web)}</p>
|
||||||
{dialog === "github-copilot" && <p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="copilot-policy-notice">{COPILOT_POLICY_NOTICE}</p>}
|
{dialog === "github-copilot" && <p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="copilot-policy-notice">{COPILOT_POLICY_NOTICE}</p>}
|
||||||
</> : <>
|
</> : <>
|
||||||
<div className="mt-4 flex items-center gap-3 text-xs text-foreground">
|
<div className="mt-4 flex items-center gap-3 text-xs text-foreground">
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { BrailleSpinner } from "@/components/braille-spinner";
|
||||||
|
import { SettingsRow, Toggle } from "./controls.tsx";
|
||||||
|
|
||||||
|
/** Observational snapshot from electron/main.cjs; never updater permission. */
|
||||||
|
export type UpdateState = {
|
||||||
|
status: "idle" | "checking" | "downloading" | "up-to-date" | "downloaded" | "error" | "disabled";
|
||||||
|
version: string | null;
|
||||||
|
revision: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const APP_VERSION = process.env.LH_VERSION ?? "";
|
||||||
|
const HOMEPAGE = process.env.LH_HOMEPAGE ?? "";
|
||||||
|
export const MANUAL_UPDATE_HINT = "Checking downloads available updates. Downloaded updates install when you quit the app.";
|
||||||
|
export const AUTO_UPDATE_HINT = "Check at startup and every 24 hours. Turning this off does not stop updates already in progress or ready to install.";
|
||||||
|
const UPDATE_ERROR = "Couldn’t check for updates. Try again later.";
|
||||||
|
|
||||||
|
/** Subscribe before requesting the snapshot. IPC replies and pushes can
|
||||||
|
* cross; a revision prevents an older reply from hiding a newer event.
|
||||||
|
* The banner shares this read-only observer so late loads recover too. */
|
||||||
|
export function useUpdateState() {
|
||||||
|
const [state, setState] = useState<UpdateState | null>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const lh = window.lh;
|
||||||
|
if (!lh) return;
|
||||||
|
let live = true;
|
||||||
|
let revision = -1;
|
||||||
|
const accept = (next: UpdateState) => {
|
||||||
|
if (!live || next.revision < revision) return;
|
||||||
|
revision = next.revision;
|
||||||
|
setState(next);
|
||||||
|
setFailed(false);
|
||||||
|
};
|
||||||
|
const off = lh.onUpdateStatus?.(accept);
|
||||||
|
void lh.getUpdateState?.().then(accept).catch(() => {
|
||||||
|
if (live && revision === -1) setFailed(true);
|
||||||
|
});
|
||||||
|
return () => { live = false; off?.(); };
|
||||||
|
}, []);
|
||||||
|
return { state, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateStatusText(state: UpdateState | null): string | null {
|
||||||
|
switch (state?.status) {
|
||||||
|
case "checking": return "Checking for updates…";
|
||||||
|
case "downloading": return state.version ? `Downloading version ${state.version}…` : "Downloading an update…";
|
||||||
|
case "up-to-date": return "No newer version was found at the last check.";
|
||||||
|
case "downloaded": return `${state.version ? `Version ${state.version}` : "An update"} is ready. It will install when you quit the app.`;
|
||||||
|
case "error": return UPDATE_ERROR;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Version information is useful everywhere; update controls only belong
|
||||||
|
* to a shell with an available updater. Opening this row never checks. */
|
||||||
|
export function UpdateSettings({
|
||||||
|
autoUpdate,
|
||||||
|
onAutoUpdateChange,
|
||||||
|
version = APP_VERSION,
|
||||||
|
homepage = HOMEPAGE,
|
||||||
|
}: {
|
||||||
|
autoUpdate: boolean;
|
||||||
|
onAutoUpdateChange: (on: boolean) => void;
|
||||||
|
version?: string;
|
||||||
|
homepage?: string;
|
||||||
|
}) {
|
||||||
|
const { state, failed } = useUpdateState();
|
||||||
|
const [checkFailed, setCheckFailed] = useState(false);
|
||||||
|
useEffect(() => setCheckFailed(false), [state]);
|
||||||
|
const active = state !== null && state.status !== "disabled";
|
||||||
|
const busy = state?.status === "checking" || state?.status === "downloading";
|
||||||
|
const status = state?.status === "downloaded" ? updateStatusText(state)
|
||||||
|
: failed || checkFailed ? UPDATE_ERROR : updateStatusText(state);
|
||||||
|
const releaseNotes = version && homepage ? `${homepage}/releases/tag/v${version}` : null;
|
||||||
|
const check = () => {
|
||||||
|
setCheckFailed(false);
|
||||||
|
void window.lh?.checkForUpdates?.().catch(() => setCheckFailed(true));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsRow
|
||||||
|
label={`Version ${version || "unknown"}`}
|
||||||
|
testid="row-version"
|
||||||
|
hint={
|
||||||
|
<>
|
||||||
|
{status && <span className="block" role="status" data-testid="update-status">{status}</span>}
|
||||||
|
{active && <span className="block">{MANUAL_UPDATE_HINT}</span>}
|
||||||
|
{releaseNotes && <a href={releaseNotes} target="_blank" rel="noopener noreferrer" className="underline-offset-2 hover:text-foreground hover:underline" data-testid="release-notes">Release notes</a>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{active && (state.status === "downloaded" ? (
|
||||||
|
<Button type="button" size="xs" className="cursor-pointer" data-testid="update-restart" onClick={() => window.lh?.installUpdate?.()}>Restart</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="button" size="xs" variant="outline" className="cursor-pointer disabled:cursor-not-allowed" data-testid="update-check" disabled={busy} aria-busy={busy} onClick={check}>
|
||||||
|
{busy && <BrailleSpinner label={state.status === "downloading" ? "Downloading update" : "Checking for updates"} />}
|
||||||
|
Check for updates
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</SettingsRow>
|
||||||
|
{active && (
|
||||||
|
<SettingsRow label="Automatic updates" hint={AUTO_UPDATE_HINT} testid="row-auto-update">
|
||||||
|
<Toggle checked={autoUpdate} label="auto update" onChange={onAutoUpdateChange} />
|
||||||
|
</SettingsRow>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,9 +15,10 @@
|
|||||||
* its own save), the same re-read pattern chat.tsx uses for "models".
|
* its own save), the same re-read pattern chat.tsx uses for "models".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AuthKeyStatus } from "@/src/core";
|
import { useEffect, useState } from "react";
|
||||||
|
import { MODEL_SLOTS, type AuthKeyStatus } from "@/src/core";
|
||||||
import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section";
|
import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section";
|
||||||
import { useModelCatalog } from "@/components/model-picker";
|
import { useModelCatalog } from "@/components/model-catalog";
|
||||||
import type { ModelCatalogResponse } from "@/src/core";
|
import type { ModelCatalogResponse } from "@/src/core";
|
||||||
|
|
||||||
/** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses
|
/** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses
|
||||||
@@ -32,6 +33,33 @@ export function hasProviderKey(
|
|||||||
return classifyKeys(statuses, providers).providerRows.some((s) => s.present);
|
return classifyKeys(statuses, providers).providerRows.some((s) => s.present);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Completeness is saved identity, not current provider availability. */
|
||||||
|
export function savedSlotsConfigured(slots: ModelCatalogResponse["slots"] | undefined): boolean {
|
||||||
|
return MODEL_SLOTS.every((slot) => !!slots?.[slot]?.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the last known completeness during connection invalidation, but
|
||||||
|
* follow ordinary slot repairs as soon as their shared snapshot arrives. */
|
||||||
|
export function useModelSetup(initialConfigured: boolean) {
|
||||||
|
const { catalog } = useModelCatalog();
|
||||||
|
const [lastConfigured, setLastConfigured] = useState(initialConfigured);
|
||||||
|
const configured = catalog ? savedSlotsConfigured(catalog.slots) : lastConfigured;
|
||||||
|
useEffect(() => { if (catalog) setLastConfigured(savedSlotsConfigured(catalog.slots)); }, [catalog]);
|
||||||
|
return { catalog, configured, zeroKeys: catalog ? !catalog.providers.some((p) => p.keyPresent) : false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A known unavailable route needs an explicit replacement, never an
|
||||||
|
* automatic provider/model substitution. Unknown metadata defers to the
|
||||||
|
* server's admission gate instead of declaring a route disconnected. */
|
||||||
|
export function selectedModelUnavailable(catalog: ModelCatalogResponse | undefined, selected: string | undefined): boolean {
|
||||||
|
return !!catalog && !!selected && !catalog.models.some((m) => `${m.provider}:${m.id}` === selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reuse the ordinary control (also opened by the existing keyboard action). */
|
||||||
|
export function openModelPicker(): void {
|
||||||
|
window.dispatchEvent(new CustomEvent("lh:kbd-open-model-picker"));
|
||||||
|
}
|
||||||
|
|
||||||
/** The zero-keys truth for the calling page: TRUE only after a
|
/** The zero-keys truth for the calling page: TRUE only after a
|
||||||
* successful read says no provider key exists (pending and failed
|
* successful read says no provider key exists (pending and failed
|
||||||
* reads render nothing — the pill must never flash on a gap, and a
|
* reads render nothing — the pill must never flash on a gap, and a
|
||||||
|
|||||||
+123
-99
@@ -13,7 +13,8 @@
|
|||||||
* STAGED BY DEPENDENCY (owner, 2026-09-20: "first keys, then models —
|
* STAGED BY DEPENDENCY (owner, 2026-09-20: "first keys, then models —
|
||||||
* there's no point presenting the model picker before the user has set a
|
* there's no point presenting the model picker before the user has set a
|
||||||
* key because there are no models to pick then"):
|
* key because there are no models to pick then"):
|
||||||
* Stage 1 — no keyed provider: a SELF-CONTAINED first-key screen
|
* Unknown catalog — neutral loading (or error/Retry), never a key screen.
|
||||||
|
* Stage 1 — no keyed provider or saved selection: a SELF-CONTAINED first-key screen
|
||||||
* (zero-keys wave, owner 2026-09-25): provider picker + one key
|
* (zero-keys wave, owner 2026-09-25): provider picker + one key
|
||||||
* input + save via PUT /api/keys/:id. The dialog STAYS MOUNTED —
|
* input + save via PUT /api/keys/:id. The dialog STAYS MOUNTED —
|
||||||
* saving dispatches the "keys" signal, the fetch effect re-runs on
|
* saving dispatches the "keys" signal, the fetch effect re-runs on
|
||||||
@@ -23,6 +24,9 @@
|
|||||||
* Stage 2 — Strong → Cheap → Vision pickers over the FEDERATED catalog
|
* Stage 2 — Strong → Cheap → Vision pickers over the FEDERATED catalog
|
||||||
* (keyed providers only), the vision slot filtered to image-capable
|
* (keyed providers only), the vision slot filtered to image-capable
|
||||||
* models, in the app's shared dropdown language (ModelMenuList).
|
* models, in the app's shared dropdown language (ModelMenuList).
|
||||||
|
* Saved disconnected selections and edits are retained internally in
|
||||||
|
* the exclusive provider stage. Connecting with all slots already saved
|
||||||
|
* closes setup; otherwise the pickers require only the missing slots.
|
||||||
* All three required (owner: no skip, no defaults); Save PUTs the
|
* All three required (owner: no skip, no defaults); Save PUTs the
|
||||||
* models section and reports onDone(models).
|
* models section and reports onDone(models).
|
||||||
*
|
*
|
||||||
@@ -41,6 +45,8 @@ import { prettyModel } from "@/components/usage-format";
|
|||||||
import { ModelMenuList } from "@/components/settings/model-menu";
|
import { ModelMenuList } from "@/components/settings/model-menu";
|
||||||
import { MenuShell } from "@/components/menu-shell";
|
import { MenuShell } from "@/components/menu-shell";
|
||||||
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
|
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
|
||||||
|
import { useModelCatalog } from "@/components/model-catalog";
|
||||||
|
import { savedSlotsConfigured } from "@/components/setup-cta";
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
import { type ProviderKeyInfo } from "@/components/settings/keys-section";
|
import { type ProviderKeyInfo } from "@/components/settings/keys-section";
|
||||||
import { ProviderMenuList } from "@/components/settings/model-menu";
|
import { ProviderMenuList } from "@/components/settings/model-menu";
|
||||||
@@ -73,27 +79,26 @@ interface ModelsBody {
|
|||||||
* (owner's dependency ordering). Exported for tests. */
|
* (owner's dependency ordering). Exported for tests. */
|
||||||
export function setupStage(
|
export function setupStage(
|
||||||
providers: { id: string; keyPresent: boolean }[] | null,
|
providers: { id: string; keyPresent: boolean }[] | null,
|
||||||
): "keys" | "pickers" {
|
): "loading" | "keys" | "pickers" {
|
||||||
if (providers === null) return "keys"; // fetch failed → the honest fallback
|
if (providers === null) return "loading";
|
||||||
return providers.some((p) => p.keyPresent) ? "pickers" : "keys";
|
return providers.some((p) => p.keyPresent) ? "pickers" : "keys";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prefill (owner, BYOK wave 2026-09-22): each pinned tier opens with its
|
type ModelSelection = Pick<ModelCatalogEntry, "provider" | "id">;
|
||||||
* pick — matched in the FEDERATED catalog (a lapsed key's pin does not
|
|
||||||
* prefill); unpinned tiers stay empty and carry the "needed" mark.
|
/** Saved identity is independent of selectable metadata. A disconnected
|
||||||
* First-run = nothing pinned → all three empty + marked. Exported pure
|
* pin still prefills; only the menu is restricted to the live catalog. */
|
||||||
* for tests. */
|
|
||||||
export function prefillFromSlots(
|
export function prefillFromSlots(
|
||||||
models: ModelCatalogEntry[],
|
models: ModelCatalogEntry[],
|
||||||
slots: Record<ModelSlotName, { model: { provider: string; id: string } } | null> | undefined,
|
slots: Record<ModelSlotName, { model: { provider: string; id: string } } | null> | undefined,
|
||||||
): Partial<Record<ModelSlotName, ModelCatalogEntry>> {
|
): Partial<Record<ModelSlotName, ModelSelection>> {
|
||||||
const pre: Partial<Record<ModelSlotName, ModelCatalogEntry>> = {};
|
const pre: Partial<Record<ModelSlotName, ModelSelection>> = {};
|
||||||
for (const slot of SLOT_ORDER) {
|
for (const slot of SLOT_ORDER) {
|
||||||
const pin = slots?.[slot];
|
const pin = slots?.[slot];
|
||||||
const entry = pin?.model
|
const entry = pin?.model
|
||||||
? models.find((m) => m.provider === pin.model!.provider && m.id === pin.model!.id)
|
? models.find((m) => m.provider === pin.model!.provider && m.id === pin.model!.id)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (entry) pre[slot] = entry;
|
if (pin?.model) pre[slot] = entry ?? pin.model;
|
||||||
}
|
}
|
||||||
return pre;
|
return pre;
|
||||||
}
|
}
|
||||||
@@ -217,24 +222,38 @@ export function SetupKeysBody({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetupDialog({
|
function prefillLevels(body: ModelsBody | undefined): Partial<Record<ModelSlotName, ThinkingLevel>> {
|
||||||
open,
|
const levels: Partial<Record<ModelSlotName, ThinkingLevel>> = {};
|
||||||
onClose,
|
for (const slot of SLOT_ORDER) {
|
||||||
/** Saved: the models section landed; the caller marks configured (the
|
const pin = body?.slots?.[slot];
|
||||||
* draft stays put — save never sends). */
|
const entry = body?.models.find((m) => m.provider === pin?.model.provider && m.id === pin.model.id);
|
||||||
onDone,
|
if (pin?.thinkingLevel !== undefined) levels[slot] = pin.thinkingLevel;
|
||||||
}: {
|
else if (entry) levels[slot] = prefillLevelFromPin(entry, pin);
|
||||||
|
}
|
||||||
|
return levels;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetupDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/** Saving never sends the caller's draft. */
|
||||||
onDone: (models: ModelsSettings) => void;
|
onDone: (models: ModelsSettings) => void;
|
||||||
}) {
|
}
|
||||||
|
|
||||||
|
export function SetupDialog(props: SetupDialogProps) {
|
||||||
|
// An open owns its draft. Mount-time cache seeding avoids a key-screen
|
||||||
|
// first frame, and reopening cannot reuse a previous open's edits.
|
||||||
|
return props.open ? <OpenSetupDialog {...props} /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
||||||
const backdropDismiss = useBackdropDismiss(onClose);
|
const backdropDismiss = useBackdropDismiss(onClose);
|
||||||
const [body, setBody] = useState<ModelsBody | null>(null);
|
const { catalog, failed, refresh } = useModelCatalog();
|
||||||
const [failed, setFailed] = useState(false);
|
const providerStageSeen = useRef(false);
|
||||||
const [picked, setPicked] = useState<Partial<Record<ModelSlotName, ModelCatalogEntry>>>({});
|
const connectionCloseRequested = useRef(false);
|
||||||
/** Per-slot thinking level (chunk 2): populated from the ordering on
|
const [picked, setPicked] = useState(() => prefillFromSlots(catalog?.models ?? [], catalog?.slots));
|
||||||
* every fresh model pick, editable — an explicit pick always wins. */
|
const [levels, setLevels] = useState(() => prefillLevels(catalog));
|
||||||
const [levels, setLevels] = useState<Partial<Record<ModelSlotName, ThinkingLevel>>>({});
|
const editedSlots = useRef(new Set<ModelSlotName>());
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [openMenu, setOpenMenu] = useState<{ slot: ModelSlotName; kind: "model" | "thinking" } | null>(null);
|
const [openMenu, setOpenMenu] = useState<{ slot: ModelSlotName; kind: "model" | "thinking" } | null>(null);
|
||||||
@@ -249,64 +268,65 @@ export function SetupDialog({
|
|||||||
const [keyError, setKeyError] = useState<string | null>(null);
|
const [keyError, setKeyError] = useState<string | null>(null);
|
||||||
const [keyMenuOpen, setKeyMenuOpen] = useState(false);
|
const [keyMenuOpen, setKeyMenuOpen] = useState(false);
|
||||||
// Stage 1's provider-picker trigger wrapper — the shared shell's anchor.
|
// Stage 1's provider-picker trigger wrapper — the shared shell's anchor.
|
||||||
// Declared with the other hooks: the early `if (!open) return null` sits
|
// Stage 2's slot menus anchor by anchorTestid instead — see the slot
|
||||||
// below them all. (Stage 2's slot menus anchor by anchorTestid instead —
|
// MenuShell below for why a freshly attached ref cannot work there.
|
||||||
// see the slot MenuShell below for why a ref cannot work there.)
|
|
||||||
const keyTriggerRef = useRef<HTMLDivElement>(null);
|
const keyTriggerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// The keys signal (zero-keys wave): keys-section saves/deletes AND
|
// The keys signal (zero-keys wave): keys-section saves/deletes AND
|
||||||
// THIS dialog's own first-key save dispatch it; the fetch effect below
|
// THIS dialog's own first-key save dispatch it; the fetch effect below
|
||||||
// re-runs on it, so saving a key flips the stage to the pickers with
|
// re-runs on it, so saving a key widens the catalog. Incomplete saved
|
||||||
// the widened catalog — no restart, no Settings round-trip.
|
// slots advance to pickers; complete saved slots just close setup.
|
||||||
const [keysTick, setKeysTick] = useState(0);
|
const [keysTick, setKeysTick] = useState(0);
|
||||||
useSettingsSaved((section) => {
|
useSettingsSaved((section) => {
|
||||||
if (section === "keys") setKeysTick((t) => t + 1);
|
if (section === "keys") setKeysTick((t) => t + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fresh /api/models per open AND per keys change — a key saved a
|
|
||||||
// moment ago (stage 1's own screen) widens the pickers immediately.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setBody(null);
|
|
||||||
setPicked({});
|
|
||||||
setLevels({});
|
|
||||||
setError(null);
|
|
||||||
fetch("/api/keys")
|
fetch("/api/keys")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((b: { providers?: ProviderKeyInfo[] }) => { if (!cancelled) setKeyProviders(b.providers ?? []); })
|
.then((b: { providers?: ProviderKeyInfo[] }) => { if (!cancelled) setKeyProviders(b.providers ?? []); })
|
||||||
.catch(() => { if (!cancelled) setKeyProviders([]); });
|
.catch(() => { if (!cancelled) setKeyProviders([]); });
|
||||||
fetch("/api/models")
|
|
||||||
.then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); })
|
|
||||||
.then((b: ModelsBody) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setBody(b);
|
|
||||||
setFailed(false);
|
|
||||||
// Prefill (owner, 2026-09-22): each pinned tier opens with its
|
|
||||||
// pick (matched in the FEDERATED catalog — a lapsed key's pin
|
|
||||||
// does not prefill); unpinned tiers stay empty and carry the
|
|
||||||
// "needed" mark. First-run = nothing pinned → all three marked.
|
|
||||||
const pre = prefillFromSlots(b.models, b.slots);
|
|
||||||
setPicked(pre);
|
|
||||||
// Levels prefill with the pins (chunk 2): stored level if still
|
|
||||||
// supported, else the ordering's level for the pinned model.
|
|
||||||
const preLevels: Partial<Record<ModelSlotName, ThinkingLevel>> = {};
|
|
||||||
for (const slot of SLOT_ORDER) {
|
|
||||||
const entry = pre[slot];
|
|
||||||
if (entry) preLevels[slot] = prefillLevelFromPin(entry, b.slots?.[slot]);
|
|
||||||
}
|
|
||||||
setLevels(preLevels);
|
|
||||||
})
|
|
||||||
.catch(() => { if (!cancelled) setFailed(true); });
|
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [open, keysTick]);
|
}, [keysTick]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!catalog) return;
|
||||||
|
const pre = prefillFromSlots(catalog.models, catalog.slots);
|
||||||
|
const preLevels = prefillLevels(catalog);
|
||||||
|
// Fresh metadata is always used, but a response cannot reset an
|
||||||
|
// edited slot, including a level chosen while the request was pending.
|
||||||
|
setPicked((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const slot of SLOT_ORDER) if (!editedSlots.current.has(slot)) {
|
||||||
|
if (pre[slot]) next[slot] = pre[slot]; else delete next[slot];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setLevels((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const slot of SLOT_ORDER) if (!editedSlots.current.has(slot)) {
|
||||||
|
if (preLevels[slot] !== undefined) next[slot] = preLevels[slot]; else delete next[slot];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [catalog]);
|
||||||
|
|
||||||
|
const stage = setupStage(catalog?.providers ?? null);
|
||||||
|
const closeAfterConnection = stage === "pickers" && providerStageSeen.current && savedSlotsConfigured(catalog?.slots);
|
||||||
|
useEffect(() => {
|
||||||
|
if (stage === "keys") providerStageSeen.current = true;
|
||||||
|
if (closeAfterConnection && !connectionCloseRequested.current) {
|
||||||
|
connectionCloseRequested.current = true;
|
||||||
|
onClose(); // Connection recovery is not a model-settings save.
|
||||||
|
}
|
||||||
|
}, [stage, closeAfterConnection, onClose]);
|
||||||
|
|
||||||
// Esc + outside-click — layered dismissal, closable WITHOUT
|
// Esc + outside-click — layered dismissal, closable WITHOUT
|
||||||
// configuring (browsing stays possible; the gate is the send
|
// configuring (browsing stays possible; the gate is the send
|
||||||
// interception, not this dialog). An open slot menu eats Esc first
|
// interception, not this dialog). An open slot menu eats Esc first
|
||||||
// (and any click outside it); only a bare Esc closes the dialog.
|
// (and any click outside it); only a bare Esc closes the dialog.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key !== "Escape") return;
|
if (e.key !== "Escape") return;
|
||||||
// THE ONE-ESC LAW (owner, keyboard round 2026-10-06): claim at
|
// THE ONE-ESC LAW (owner, keyboard round 2026-10-06): claim at
|
||||||
@@ -341,17 +361,18 @@ export function SetupDialog({
|
|||||||
document.removeEventListener("keydown", onKey);
|
document.removeEventListener("keydown", onKey);
|
||||||
document.removeEventListener("pointerdown", onPointerDown);
|
document.removeEventListener("pointerdown", onPointerDown);
|
||||||
};
|
};
|
||||||
}, [open, onClose, openMenu, keyMenuOpen]);
|
}, [onClose, openMenu, keyMenuOpen]);
|
||||||
|
|
||||||
if (!open) return null;
|
// Do not briefly paint model pickers between successful connection and
|
||||||
|
// closing an already-configured setup. No saved model/thinking writes.
|
||||||
|
if (closeAfterConnection) return null;
|
||||||
|
|
||||||
const stage = failed ? "keys" : setupStage(body ? body.providers : null);
|
|
||||||
const allPicked = SLOT_ORDER.every((s) => picked[s]);
|
const allPicked = SLOT_ORDER.every((s) => picked[s]);
|
||||||
|
|
||||||
/** Stage 1's save: PUT the pasted key, dispatch the keys signal, and
|
/** Stage 1's save: PUT the pasted key, dispatch the keys signal, and
|
||||||
* CLEAN the local screen — the signal re-fetch widens the catalog and
|
* CLEAN the local key field. The fresh catalog decides whether to
|
||||||
* the stage flips to the pickers on its own; the dialog stays mounted
|
* close recovery or advance to the missing model slots. Shape-gate
|
||||||
* the whole way. Shape-gate 400s keep their user-facing line inline. */
|
* 400s keep their user-facing line inline. */
|
||||||
async function saveKey() {
|
async function saveKey() {
|
||||||
if (!keyProvider || !keyValue.trim() || keySaving) return;
|
if (!keyProvider || !keyValue.trim() || keySaving) return;
|
||||||
setKeySaving(true);
|
setKeySaving(true);
|
||||||
@@ -398,6 +419,20 @@ export function SetupDialog({
|
|||||||
onDone(models);
|
onDone(models);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const keysBody = <SetupKeysBody
|
||||||
|
providers={keyProviders}
|
||||||
|
provider={keyProvider}
|
||||||
|
value={keyValue}
|
||||||
|
saving={keySaving}
|
||||||
|
error={keyError}
|
||||||
|
menuOpen={keyMenuOpen}
|
||||||
|
menuAnchor={keyTriggerRef}
|
||||||
|
onToggleMenu={() => setKeyMenuOpen((v) => !v)}
|
||||||
|
onPick={(p) => { setKeyProvider(p); setKeyMenuOpen(false); setKeyError(null); }}
|
||||||
|
onValue={setKeyValue}
|
||||||
|
onSave={() => void saveKey()}
|
||||||
|
/>;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
|
||||||
@@ -427,32 +462,15 @@ export function SetupDialog({
|
|||||||
</Hint>
|
</Hint>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{stage === "keys" ? (
|
{failed && (
|
||||||
<div className="flex flex-col px-6 py-5">
|
<p role="alert" data-testid="setup-catalog-error" className="px-6 py-3 text-sm text-foreground">
|
||||||
{failed ? (
|
Model catalog could not be loaded. Check the connection and retry. <button type="button" data-testid="setup-retry" className="underline" onClick={refresh}>Retry</button>
|
||||||
<p className="text-sm text-foreground">
|
</p>
|
||||||
Model catalog could not be loaded. Check the connection and retry.
|
)}
|
||||||
</p>
|
{stage === "loading" ? (
|
||||||
) : (
|
!failed && <p data-testid="setup-stage-loading" role="status" className="px-6 py-5 text-xs text-muted-foreground">Loading models…</p>
|
||||||
<SetupKeysBody
|
) : stage === "keys" ? (
|
||||||
providers={keyProviders}
|
<div className="flex flex-col px-6 py-5">{keysBody}</div>
|
||||||
provider={keyProvider}
|
|
||||||
value={keyValue}
|
|
||||||
saving={keySaving}
|
|
||||||
error={keyError}
|
|
||||||
menuOpen={keyMenuOpen}
|
|
||||||
menuAnchor={keyTriggerRef}
|
|
||||||
onToggleMenu={() => setKeyMenuOpen((v) => !v)}
|
|
||||||
onPick={(p) => {
|
|
||||||
setKeyProvider(p);
|
|
||||||
setKeyMenuOpen(false);
|
|
||||||
setKeyError(null);
|
|
||||||
}}
|
|
||||||
onValue={setKeyValue}
|
|
||||||
onSave={() => void saveKey()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4 overflow-y-auto px-6 py-5" data-testid="setup-stage-pickers">
|
<div className="flex flex-col gap-4 overflow-y-auto px-6 py-5" data-testid="setup-stage-pickers">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -461,14 +479,16 @@ export function SetupDialog({
|
|||||||
{SLOT_ORDER.map((slot) => {
|
{SLOT_ORDER.map((slot) => {
|
||||||
const entry = picked[slot];
|
const entry = picked[slot];
|
||||||
const level = levels[slot];
|
const level = levels[slot];
|
||||||
const levelsForEntry = entry?.supportedLevels ?? [];
|
const metadata = catalog?.models.find((m) => m.id === entry?.id && m.provider === entry.provider);
|
||||||
const catalog =
|
// Unavailable metadata cannot erase or clamp a saved preference.
|
||||||
slot === "vision" ? body?.models.filter((m) => m.input.includes("image")) : body?.models;
|
const levelsForEntry = metadata?.supportedLevels ?? (entry ? Object.keys(THINKING_LABEL) : []);
|
||||||
|
const available = catalog?.models ?? (failed ? [] : undefined);
|
||||||
|
const slotCatalog = slot === "vision" ? available?.filter((m) => m.input.includes("image")) : available;
|
||||||
return (
|
return (
|
||||||
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5">
|
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<span className="font-ui text-sm text-foreground">{SLOT_META[slot].label}</span>
|
<span className="font-ui text-sm text-foreground">{SLOT_META[slot].label}</span>
|
||||||
{!body?.slots?.[slot]?.model && !picked[slot] && (
|
{!catalog?.slots?.[slot]?.model && !picked[slot] && (
|
||||||
<span
|
<span
|
||||||
data-testid={`setup-needed-${slot}`}
|
data-testid={`setup-needed-${slot}`}
|
||||||
className="rounded-sm border border-border px-1 py-0.5 text-[10px] font-normal tracking-wide text-muted-foreground"
|
className="rounded-sm border border-border px-1 py-0.5 text-[10px] font-normal tracking-wide text-muted-foreground"
|
||||||
@@ -481,6 +501,7 @@ export function SetupDialog({
|
|||||||
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={entry?.provider} /><NativeSubscriptionBackgroundAccess provider={entry?.provider} /></>}
|
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={entry?.provider} /><NativeSubscriptionBackgroundAccess provider={entry?.provider} /></>}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
|
<Hint label={entry ? `${entry.provider}:${entry.id}` : "Pick a model…"}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`${SLOT_META[slot].label} model`}
|
aria-label={`${SLOT_META[slot].label} model`}
|
||||||
@@ -494,6 +515,7 @@ export function SetupDialog({
|
|||||||
▾
|
▾
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</Hint>
|
||||||
{/* Anchor by TESTID, not a ref (the settings-section pattern):
|
{/* Anchor by TESTID, not a ref (the settings-section pattern):
|
||||||
React attaches a host element's ref AFTER its descendants'
|
React attaches a host element's ref AFTER its descendants'
|
||||||
layout effects run, so a ref freshly attached in this same
|
layout effects run, so a ref freshly attached in this same
|
||||||
@@ -512,10 +534,11 @@ export function SetupDialog({
|
|||||||
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
||||||
>
|
>
|
||||||
<ModelMenuList
|
<ModelMenuList
|
||||||
models={catalog}
|
models={slotCatalog}
|
||||||
effectiveId={entry?.id}
|
effectiveId={entry?.id}
|
||||||
effectiveProvider={entry?.provider}
|
effectiveProvider={entry?.provider}
|
||||||
onPick={(m) => {
|
onPick={(m) => {
|
||||||
|
editedSlots.current.add(slot);
|
||||||
setPicked((prev) => ({ ...prev, [slot]: m }));
|
setPicked((prev) => ({ ...prev, [slot]: m }));
|
||||||
// Auto-populate (chunk 2, owner: "populate
|
// Auto-populate (chunk 2, owner: "populate
|
||||||
// everywhere"): no explicit level → the
|
// everywhere"): no explicit level → the
|
||||||
@@ -527,7 +550,7 @@ export function SetupDialog({
|
|||||||
}));
|
}));
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
}}
|
}}
|
||||||
footer={catalog && catalog.length === 0 ? "No image-capable models. Add a provider key with access to a vision model." : undefined}
|
footer={slotCatalog && slotCatalog.length === 0 && slot === "vision" ? "No image-capable models. Add a provider key with access to a vision model." : undefined}
|
||||||
/>
|
/>
|
||||||
</MenuShell>
|
</MenuShell>
|
||||||
)}
|
)}
|
||||||
@@ -563,6 +586,7 @@ export function SetupDialog({
|
|||||||
levels={levelsForEntry.map(String)}
|
levels={levelsForEntry.map(String)}
|
||||||
effectiveLevel={level}
|
effectiveLevel={level}
|
||||||
onPick={(lv) => {
|
onPick={(lv) => {
|
||||||
|
editedSlots.current.add(slot);
|
||||||
setLevels((prev) => ({ ...prev, [slot]: lv as ThinkingLevel }));
|
setLevels((prev) => ({ ...prev, [slot]: lv as ThinkingLevel }));
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
}}
|
}}
|
||||||
|
|||||||
+10
-2
@@ -49,6 +49,7 @@ const STORAGE_KEY = "lh:sidebar-open";
|
|||||||
* Mirrors SIDEBAR_AUTOCLOSE in electron/main.cjs — keep in sync.
|
* Mirrors SIDEBAR_AUTOCLOSE in electron/main.cjs — keep in sync.
|
||||||
*/
|
*/
|
||||||
import type { ChatGPTControlBridge, NativeSubscriptionControlBridge } from "@/src/core";
|
import type { ChatGPTControlBridge, NativeSubscriptionControlBridge } from "@/src/core";
|
||||||
|
import type { UpdateState } from "@/components/settings/update-settings.tsx";
|
||||||
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).
|
||||||
@@ -60,15 +61,22 @@ 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;
|
||||||
|
/** External Next desktop-dev uses the server's web connections. */
|
||||||
|
subscriptionTransport?: "desktop" | "web";
|
||||||
|
openSubscriptionBrowser?: (action: string) => Promise<void>;
|
||||||
chatgpt?: ChatGPTControlBridge;
|
chatgpt?: ChatGPTControlBridge;
|
||||||
subscriptions?: NativeSubscriptionControlBridge;
|
subscriptions?: NativeSubscriptionControlBridge;
|
||||||
expandWindow: () => void;
|
expandWindow: () => void;
|
||||||
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
|
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
|
||||||
onZoom?: (cb: (factor: number) => void) => () => void;
|
onZoom?: (cb: (factor: number) => void) => () => void;
|
||||||
getZoom?: () => Promise<number>;
|
getZoom?: () => Promise<number>;
|
||||||
/** Auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes once a staged update exists. */
|
/** Observational update events and a snapshot for late subscribers. */
|
||||||
onUpdateStatus?: (cb: (status: { status: string; version: string | null }) => void) => () => void;
|
onUpdateStatus?: (cb: (status: UpdateState) => void) => () => void;
|
||||||
|
getUpdateState?: () => Promise<UpdateState>;
|
||||||
|
/** Explicit manual check also downloads and stages available updates. */
|
||||||
|
checkForUpdates?: () => Promise<void>;
|
||||||
installUpdate?: () => void;
|
installUpdate?: () => void;
|
||||||
|
onOpenSettings?: (cb: (section: string) => void) => () => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { subscriptionLabels, subscriptionLabel, type SessionPricing, type TurnUsage } from "@/src/core";
|
import { subscriptionLabels, subscriptionLabel, type SessionPricing, type TurnUsage } from "@/src/core";
|
||||||
import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format";
|
import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format";
|
||||||
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
|
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
|
||||||
@@ -34,6 +34,7 @@ export function StatsBar({
|
|||||||
dictationSlot,
|
dictationSlot,
|
||||||
chatId,
|
chatId,
|
||||||
leadingSlot,
|
leadingSlot,
|
||||||
|
onModelChange,
|
||||||
}: {
|
}: {
|
||||||
turns: TurnUsage[];
|
turns: TurnUsage[];
|
||||||
contextTokens?: number | null;
|
contextTokens?: number | null;
|
||||||
@@ -43,6 +44,8 @@ export function StatsBar({
|
|||||||
* record; absent on the draft surfaces, where it binds the strong
|
* record; absent on the draft surfaces, where it binds the strong
|
||||||
* slot (the new-session default). */
|
* slot (the new-session default). */
|
||||||
chatId?: string;
|
chatId?: string;
|
||||||
|
/** The same effective identity the visible model control owns. */
|
||||||
|
onModelChange?: (model: string | undefined) => void;
|
||||||
/** The dictation strip (STT wave, 2026-09-17): the row's middle is dead
|
/** The dictation strip (STT wave, 2026-09-17): the row's middle is dead
|
||||||
* space between the session data and the model control — the mic
|
* space between the session data and the model control — the mic
|
||||||
* button lives there, and while recording the meter unfurls to fill
|
* button lives there, and while recording the meter unfurls to fill
|
||||||
@@ -55,6 +58,10 @@ export function StatsBar({
|
|||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [selectedModel, setSelectedModel] = useState<string | undefined>();
|
const [selectedModel, setSelectedModel] = useState<string | undefined>();
|
||||||
|
const modelChanged = useCallback((model: string | undefined) => {
|
||||||
|
setSelectedModel(model);
|
||||||
|
onModelChange?.(model);
|
||||||
|
}, [onModelChange]);
|
||||||
// Same dropdown discipline as the model control (owner, df4433f treatment,
|
// Same dropdown discipline as the model control (owner, df4433f treatment,
|
||||||
// stats round 1): close on any pointerdown outside the bar or on Escape.
|
// stats round 1): close on any pointerdown outside the bar or on Escape.
|
||||||
const barRef = useRef<HTMLDivElement>(null);
|
const barRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -192,7 +199,7 @@ export function StatsBar({
|
|||||||
{/* The model + thinking control — one controller for the conversation
|
{/* The model + thinking control — one controller for the conversation
|
||||||
agents (prober/teacher); the record's `turns` above still folds
|
agents (prober/teacher); the record's `turns` above still folds
|
||||||
the ACTUAL models billed, regardless of what the picker says. */}
|
the ACTUAL models billed, regardless of what the picker says. */}
|
||||||
<ModelControl chatId={chatId} onModelChange={setSelectedModel} />
|
<ModelControl chatId={chatId} onModelChange={modelChanged} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,32 +3,19 @@
|
|||||||
/**
|
/**
|
||||||
* The auto-update banner (Wave C, owner 2026-10-10: silent download +
|
* The auto-update banner (Wave C, owner 2026-10-10: silent download +
|
||||||
* restart-banner UX). The shell pushes "lh:update-status" once a staged
|
* restart-banner UX). The shell pushes "lh:update-status" once a staged
|
||||||
* update exists (electron/main.cjs update-downloaded); this is the whole
|
* update exists (electron/main.cjs update-downloaded). Snapshot retrieval
|
||||||
* renderer surface — a small fixed card, bottom-right, offering Restart.
|
* also covers a late load. The card offers Restart alongside Settings.
|
||||||
* Browser builds have no window.lh and never see a push: renders null.
|
* Browser builds have no window.lh and never see a push: renders null.
|
||||||
*
|
*
|
||||||
* No dismiss: a downloaded update installs on quit anyway
|
* No dismiss: a downloaded update installs on quit anyway
|
||||||
* (autoInstallOnAppQuit); the banner is a courtesy, not a gate.
|
* (autoInstallOnAppQuit); the banner is a courtesy, not a gate.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useUpdateState } from "@/components/settings/update-settings.tsx";
|
||||||
|
|
||||||
// The window.lh bridge type lives in components/sidebar.tsx (the incumbent
|
|
||||||
// declare-global) — onUpdateStatus/installUpdate are declared there too.
|
|
||||||
|
|
||||||
type UpdateStatus = { status: string; version: string | null };
|
|
||||||
|
|
||||||
export default function UpdateBanner() {
|
export default function UpdateBanner() {
|
||||||
const [ready, setReady] = useState<UpdateStatus | null>(null);
|
const { state: ready } = useUpdateState();
|
||||||
|
if (ready?.status !== "downloaded") return null;
|
||||||
useEffect(() => {
|
|
||||||
// Absent bridge (browser / dev) → stays hidden forever.
|
|
||||||
return window.lh?.onUpdateStatus?.((s) => {
|
|
||||||
if (s?.status === "downloaded") setReady(s);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!ready) return null;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="update-banner"
|
data-testid="update-banner"
|
||||||
|
|||||||
@@ -29,25 +29,11 @@ export function splitQualified(id: string): { provider: string | null; id: strin
|
|||||||
return { provider: id.slice(0, colon), id: id.slice(colon + 1) };
|
return { provider: id.slice(0, colon), id: id.slice(colon + 1) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Model-only labels omit the vendor path, never versions, dates or variants.
|
||||||
* "z-ai/glm-5.3-flash" → "glm-5.3-flash" — vendor prefix dropped, the
|
* Only usage callers pass `qualified`: a bare catalog id's colon belongs to
|
||||||
* name KEPT EXACTLY as its provider writes it (dashes stay, no
|
* the model (e.g. :free), not a provider. Usage retains its provider suffix. */
|
||||||
* auto-caps — owner rounds 4-5, 2026-10-05); a trailing all-digit
|
|
||||||
* segment (OpenRouter's date suffix, "qwen3.8-max-0902") is dropped.
|
|
||||||
* Quiet-chip friendly.
|
|
||||||
*
|
|
||||||
* With `qualified` (usage stamps are "provider:id", model-slots wave):
|
|
||||||
* the provider rides as a quiet suffix — "glm-5.3-flash · openrouter" —
|
|
||||||
* because with federation the same model via two providers is genuinely
|
|
||||||
* different bookkeeping. Bare catalog ids are unchanged.
|
|
||||||
*/
|
|
||||||
export function prettyModel(id: string, qualified = false): string {
|
export function prettyModel(id: string, qualified = false): string {
|
||||||
const { provider, id: bare } = splitQualified(id);
|
const { provider, id: bare } = qualified ? splitQualified(id) : { provider: null, id };
|
||||||
const short = bare.includes("/") ? bare.slice(bare.lastIndexOf("/") + 1) : bare;
|
const short = bare.includes("/") ? bare.slice(bare.lastIndexOf("/") + 1) : bare;
|
||||||
const parts = short.split("-");
|
return provider ? `${short} · ${provider}` : short;
|
||||||
const trimmed = /^\d+$/.test(parts[parts.length - 1] ?? "") ? parts.slice(0, -1) : parts;
|
|
||||||
// No auto-capitalization, dashes KEPT (owner rounds 4-5, 2026-10-05):
|
|
||||||
// model names render exactly as their providers write them.
|
|
||||||
const pretty = trimmed.join("-");
|
|
||||||
return qualified && provider ? `${pretty} · ${provider}` : pretty;
|
|
||||||
}
|
}
|
||||||
+24
-14
@@ -307,26 +307,34 @@ sequenceDiagram
|
|||||||
- **The prober→teacher handoff**: the first turn(s) run the prober (floor/ceiling via quiz, goal via ask); `probe_done` (server-executed) files the assessment and queues a handoff steer; from the first `tool-probe_done` part in the transcript, the handler builds the teacher agent for every later turn. Phases (`core/phases.ts`) fold strictly: probe → plan → teach.
|
- **The prober→teacher handoff**: the first turn(s) run the prober (floor/ceiling via quiz, goal via ask); `probe_done` (server-executed) files the assessment and queues a handoff steer; from the first `tool-probe_done` part in the transcript, the handler builds the teacher agent for every later turn. Phases (`core/phases.ts`) fold strictly: probe → plan → teach.
|
||||||
- **The formal plan tree is author-once**: the teacher authors the session's dependency map with a single schema-validated `plan_init` tool (a mermaid-subset graph, parsed deterministically with line-numbered errors, validated as a DAG). Progress is never mutated — statuses (`current`/`visited`/upcoming) are **derived** from tagged quiz calls in the transcript; the tree panel and the plan blocks render that derivation.
|
- **The formal plan tree is author-once**: the teacher authors the session's dependency map with a single schema-validated `plan_init` tool (a mermaid-subset graph, parsed deterministically with line-numbered errors, validated as a DAG). Progress is never mutated — statuses (`current`/`visited`/upcoming) are **derived** from tagged quiz calls in the transcript; the tree panel and the plan blocks render that derivation.
|
||||||
- **Job tools are server-executed, ack-and-steer**: `research`/`demo`/`visual` resolve immediately mid-turn with a fire-and-forget ack (the teacher keeps teaching); `spawn(job, input)` fires the background runner.
|
- **Job tools are server-executed, ack-and-steer**: `research`/`demo`/`visual` resolve immediately mid-turn with a fire-and-forget ack (the teacher keeps teaching); `spawn(job, input)` fires the background runner.
|
||||||
- Each turn service is a named, error-contained seam taking explicit deps; failures log one `[chat <id>]` line and never touch the response. Swapping any of them touches nothing else.
|
- Turn services are named seams taking explicit dependencies. Extraction contains its errors, but **canonical persistence must not fail silently**: a failed input write refuses acceptance without starting an agent; `persistTurn` throws on a failed reply write. The runner reports settlement failure before `[DONE]` and releases liveness even when normal settlement fails. Steer reconciliation uses the durable record, so a later bookkeeping failure cannot replay already-saved learner input.
|
||||||
|
- **Late attachment must still see settlement failure.** A small, memory-only notice per session precedes the settled stream terminal when the failing turn has already left the live registry. The next accepted turn, successful session deletion, or server exit clears it. Reopening seeds the existing banner from that notice and waits for ordinary learner input; dismissing the banner is not permission to automatically retry the model. No synthetic failure entry is added to the saved transcript.
|
||||||
|
- **Recovery never blindly resends an answer.** Submitted question drafts survive optimistic unmount in memory; the existing Submit action reconciles uncertain acceptance against the canonical record and running-turn state before sending again. EOF or a rejected stream read without a terminal triggers one read-only reattachment; another transport failure shows the existing error banner rather than claiming completion. Explicit completion, cancellation, and the literal-final-entry saved-error rule remain unchanged.
|
||||||
|
|
||||||
### 5.1 The wake pipeline (Workbench → Classroom)
|
### 5.1 The wake pipeline (Workbench → Classroom)
|
||||||
|
|
||||||
On sub-agent completion the runner builds a **steer message** — a user-role `UIMessage` whose text part is the model-facing result and whose `data-subagent-artifact` part carries the full result for the client (provider conversion drops data parts) — and hands it to the wake pipeline:
|
**Worker completion and session delivery are separate lifecycles.** Dispatch persists the tool input on the job before spawning. On completion, the runner saves its result, terminal worker status (`done`/`error`), and `delivery: {kind: "result", status: "pending"}` in one job-record update. It then queues the native custom **steer message** without awaiting delivery. Its content is model-facing text; details carry the UI artifact. Worker resources are released and the Background agents row disappears even if an unanswered question holds the result indefinitely. Dispatch cards say “finished,” not “delivered.” Only computing workers can be aborted; abort saves an independent pending abort notice.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
R["job runner finishes sub-agent"] -->|"deliver(steer) — injectable seam"| ENQ["enqueue steer in per-chat queue (globalThis)"]
|
R["save worker outcome + pending delivery; release worker"] --> ENQ["one registry entry per steer ID, including wake cargo"]
|
||||||
ENQ --> PUMP["pump(chatId) — one loop per chat (pumping set serializes)"]
|
ENQ --> LIVE{"live agent and background consent?"}
|
||||||
PUMP --> GATE{"turn-free AND no pending question? (400ms poll)"}
|
LIVE -->|yes| STEER["pi steering at native boundaries; answer run takes parked results"]
|
||||||
GATE -->|no| PUMP
|
LIVE -->|no| GATE["wait for turn-free, question-free, unpaused, consented session"]
|
||||||
GATE -->|yes| REFRESH["re-fetch fresh record, re-check steerDelivered (claim race)"]
|
GATE --> RUN["idle wake through the normal turn composition"]
|
||||||
REFRESH --> RUN["turnRunner(chatId, record + steer) — the SAME handleChatRequest path"]
|
RUN -->|"409"| BACKOFF["existing backoff 500→1000→2000→5000ms"]
|
||||||
RUN -->|"200"| FIN["finalizeSteer → finalizeJob (done/error)<br/>— status flips only after the wake turn persists"]
|
BACKOFF --> GATE
|
||||||
RUN -->|"409"| BACKOFF["backoff 500→1000→2000→5000ms → retry"]
|
RUN -->|"403"| HOLD["retain ownership until consent permits delivery"]
|
||||||
CRASH["crash recovery: listJobs → recoverStaleJobs (stale >15min)<br/>re-deliver results / respawn via setJobRespawn"] --> PUMP
|
RUN -->|"other failed start"| QUIET["quiet pending; next learner run or restart recovery"]
|
||||||
|
RUN --> SAVE["canonical marker persistence, not merely HTTP 200"]
|
||||||
|
STEER --> SAVE
|
||||||
|
SAVE --> FIN["acknowledge delivery; worker outcome unchanged"]
|
||||||
|
CRASH["lazy recovery + live-agent registration restore known pending jobs"] --> ENQ
|
||||||
```
|
```
|
||||||
|
|
||||||
Job status flips done/error only after the wake turn persists. Delivery is **exactly-once**: deterministic steer ids (`steer_<jobId>`, `steer_probe_<chatId>`), `steerDelivered` checks at every claim point, and `claimSteers` finalizing in-transcript steers instead of re-appending. The client adopts the wake turn through the same `turnInFlight` machinery as returning mid-stream (busy shimmer, composer gate, append-only `mergeAdopted` adoption) and renders steers as artifacts — never a bubble.
|
The per-chat registry retains ownership across live handoff, idle-wake flight, and 409/403 return; repeated submissions share one entry/promise. A deterministic marker ID in the **saved conversation** completes delivery. Streaming visibility alone does not. A failed bookkeeping acknowledgement cannot replay a saved marker, and later teaching/model failure does not reopen delivery or retry the accepted turn. Failed starts leave results durably pending without a new error UI or retry timer; the in-memory entry stays parked until a learner run or process restart. Normal question/dismissal waits and consent deferrals are not failures.
|
||||||
|
|
||||||
|
Recovery reads saved job input before legacy transcript lookup. Missing research questions fail honestly without invoking the researcher. Legacy running jobs with saved results become finished workers with pending delivery; legacy terminal jobs without delivery metadata remain untouched, rather than guessing that old outcomes should be replayed. Pending result and abort notices are restored before a newly registered agent's first steering poll, even without a preceding jobs-feed read. Deleting a session discards its pending delivery obligations; only live turns and computing workers block deletion. These guarantees use the existing JSON storage and single-process delivery registry, not a transactional queue or a promise of exactly-once model execution.
|
||||||
|
|
||||||
Sub-agents **verify, never one-shot**: the demo-builder headlessly executes its HTML in system Chrome before delivery; the visual-maker renders each SVG and looks at it (needs a vision-capable model — the `vision` slot in settings).
|
Sub-agents **verify, never one-shot**: the demo-builder headlessly executes its HTML in system Chrome before delivery; the visual-maker renders each SVG and looks at it (needs a vision-capable model — the `vision` slot in settings).
|
||||||
|
|
||||||
@@ -423,9 +431,11 @@ The Electron main is a launcher + credential/security guard, never a host for te
|
|||||||
|
|
||||||
### 8.1 Subscription connection boundary
|
### 8.1 Subscription connection boundary
|
||||||
|
|
||||||
`electron/chatgpt.cjs` retains the existing verified-account ChatGPT authentication, storage and revocation behavior. `electron/subscriptions.cjs` delegates GitHub Copilot/Grok login and refresh to the installed Pi SDK through its public provider/Models interfaces. Their single saved connections live independently in owner-only, atomically written `github-copilot-credentials.json` / `xai-credentials.json` beside the existing app credential files. Electron main alone owns durable OAuth credentials and serialized refresh; no global Pi files or API-key credential precedence are involved.
|
`electron/chatgpt.cjs` retains the existing verified-account ChatGPT authentication, storage and revocation behavior. `electron/subscriptions.cjs` delegates GitHub Copilot/Grok login and refresh to the installed Pi SDK through its public provider/Models interfaces. Their single saved connections live independently in owner-only, atomically written `github-copilot-credentials.json` / `xai-credentials.json` beside the existing app credential files. In packaged desktop mode Electron main alone owns durable OAuth credentials and serialized refresh; no global Pi files or API-key credential precedence are involved.
|
||||||
|
|
||||||
The renderer gets projected status, public device user codes, prompts and controls over origin-pinned IPC. The privately launched server child receives credential-free catalogs and scoped request auth (including native headers/account endpoints), never refresh tokens. `src/agent/subscription-bridge.ts` rejects framework-only IPC and invalidates stale connection/catalog scopes. `subscription-stream.ts` dispatches directly through native providers, retaining native protocol identity on the wire and restoring logical app identity in assistant history. Persisted routes `chatgpt`, `github-copilot-subscription` and `xai-subscription` are distinct from ordinary API routes and never fall back to them.
|
The packaged desktop renderer gets projected status, public device user codes, prompts and controls over origin-pinned IPC. The privately launched server child receives credential-free catalogs and scoped request auth (including native headers/account endpoints), never refresh tokens. `src/agent/subscription-bridge.ts` rejects framework-only IPC and invalidates stale connection/catalog scopes. `subscription-stream.ts` dispatches directly through native providers, retaining native protocol identity on the wire and restoring logical app identity in assistant history. Persisted routes `chatgpt`, `github-copilot-subscription` and `xai-subscription` are distinct from ordinary API routes and never fall back to them.
|
||||||
|
|
||||||
|
For ordinary Next web/dev, `src/server/web-subscriptions.ts` composes these same plain-Node controllers into one lazy process-global credential owner beside configured `AUTH_FILE`. The server injects the private transport through `src/agent/subscription-runtime.ts`; the agent layer never imports the server layer. Existing scoped leases, authority revisions and foreground/background abort listeners work through that transport, without exposing an HTTP token/lease operation. `src/server/web-subscription-http.ts` exposes only same-origin status/control operations. Generic `subscriptions-changed` notifications use the existing SSE notify→fetch channel; opaque one-use browser actions redirect to provider pages without putting authorization URLs in app JSON. Browser adapters preserve the UI bridge contracts; Electron dev uses the web owner with a narrow system-browser opener. Desktop markers fail closed rather than falling back to web credentials. This is the existing shared developer-server trust model, not user authentication or DNS-rebinding protection; LAN behavior is unchanged. Only one server process may own an auth directory. See [development](development.md#subscription-connections-in-webdev) for storage, browser locality and access limitations.
|
||||||
|
|
||||||
`subscription-request-cycle.ts` is the shared admission/automatic-input recovery state machine; the old ChatGPT module is its compatibility export. Background consent is independent per provider, default ON, with foreground-preserving cancellation and queue ownership retained on no-dispatch. Native usage rejected before dispatch is transiently excluded from helper usage collectors, without adding persisted provenance fields. Catalogs and read-time pricing preserve the selected connection: subscription use displays compact provider labels, API dollars remain dollars, and mixed usage joins both without estimating subscription dollars or claiming detected tiers/allowances.
|
`subscription-request-cycle.ts` is the shared admission/automatic-input recovery state machine; the old ChatGPT module is its compatibility export. Background consent is independent per provider, default ON, with foreground-preserving cancellation and queue ownership retained on no-dispatch. Native usage rejected before dispatch is transiently excluded from helper usage collectors, without adding persisted provenance fields. Catalogs and read-time pricing preserve the selected connection: subscription use displays compact provider labels, API dollars remain dollars, and mixed usage joins both without estimating subscription dollars or claiming detected tiers/allowances.
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ prompt, plan continuity, persistence, threshold, scheduling, and authorization
|
|||||||
lifecycle. It does not adopt Pi's coding summary or separate split-turn summary
|
lifecycle. It does not adopt Pi's coding summary or separate split-turn summary
|
||||||
calls. A cut that drops no active history does not launch a summarizer.
|
calls. A cut that drops no active history does not launch a summarizer.
|
||||||
|
|
||||||
|
When the active cutoff is applied, conversation requests carry the latest summary
|
||||||
|
once, followed by the retained transcript without older compaction markers. When
|
||||||
|
a subsequent compaction supplies that prior summary separately, it includes it
|
||||||
|
once, plus the newly superseded transcript without summary markers. All markers
|
||||||
|
and original messages remain stored. The existing full-history fallback for an
|
||||||
|
unresolved cutoff is unchanged, including its summary text.
|
||||||
|
|
||||||
|
A provider response stopped at its output limit (`length`) is an incomplete
|
||||||
|
summary, not a successful checkpoint. Compaction fails without changing the
|
||||||
|
existing history or prior summary; a later attempt can retry normally.
|
||||||
|
|
||||||
## Conversation meter
|
## Conversation meter
|
||||||
|
|
||||||
The numerator is the latest qualifying completed conversation response's native
|
The numerator is the latest qualifying completed conversation response's native
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ npm start # serve the production build
|
|||||||
npm test # the full suite (node:test via tsx; deterministic, no LLM, no network)
|
npm test # the full suite (node:test via tsx; deterministic, no LLM, no network)
|
||||||
npm run lint # eslint — includes the import law (architecture §2)
|
npm run lint # eslint — includes the import law (architecture §2)
|
||||||
npm run typecheck # tsc --noEmit
|
npm run typecheck # tsc --noEmit
|
||||||
|
node scripts/smoke-web-subscriptions.mjs # after build: offline Next start/dev + reload gate
|
||||||
|
|
||||||
npm run desktop:dev # next dev + Electron shell pointed at it
|
npm run desktop:dev # next dev + Electron shell pointed at it
|
||||||
npm run desktop:dist # local signed/notarized macOS arm64 build (LH_NOTARY keychain)
|
npm run desktop:dist # local signed/notarized macOS arm64 build (LH_NOTARY keychain)
|
||||||
@@ -47,6 +48,16 @@ Secrets and machine-specific paths only. Model choice and thinking budgets are t
|
|||||||
|
|
||||||
The packaged desktop app takes keys from `userData/auth.json` (BYOK, mode 0600, read/written directly by the server via `LH_AUTH_FILE`). PDFs render natively with pdfjs/Skia; SVGs use resvg.
|
The packaged desktop app takes keys from `userData/auth.json` (BYOK, mode 0600, read/written directly by the server via `LH_AUTH_FILE`). PDFs render natively with pdfjs/Skia; SVGs use resvg.
|
||||||
|
|
||||||
|
### Subscription connections in web/dev
|
||||||
|
|
||||||
|
Ordinary `npm run dev` and `npm start` support the existing **ChatGPT, GitHub Copilot and Grok** connections in Settings → Providers. No separate launcher or change to Next's listening/LAN behavior is required. `npm run desktop:dev` uses these same dev-server connections; the packaged desktop app keeps its separate main-process credentials and private IPC transport.
|
||||||
|
|
||||||
|
Web/dev saves `chatgpt-credentials.json`, `github-copilot-credentials.json` and `xai-credentials.json` **beside the configured API-key `auth.json`**: the project directory by default, or the directory containing `LH_AUTH_FILE`. These are owner-only plaintext files, not files under `data/`, desktop Application Support, or a global Pi profile. Git and standalone-build exclusions cover both the files and atomic-write temporary files. Run only one server process per credential directory: refresh serialization is process-local, not a cross-process lock.
|
||||||
|
|
||||||
|
Sign-in opens a new tab in the browser that initiated it; permit that tab if your browser blocks popups. **ChatGPT sign-in must be completed in a browser on the server's machine**, because its callback listener remains `127.0.0.1` on that machine. Copilot/Grok use device codes. In the Electron dev shell, provider pages open in the system browser instead. Once connected, credentials and refresh remain server-owned; other browsers using that server share its connections and background-use consent, just as they share its API keys. Disconnect, storage-failure handling, and provider-specific background consent retain the existing behavior; subscription routes never fall back to API-key billing.
|
||||||
|
|
||||||
|
This remains a **trusted developer/shared-server mode**, not multi-user hosting. Subscription controls require same-origin JSON/custom-header requests; browser-opening actions are short-lived, origin-bound and one-use. These checks prevent ordinary cross-origin browser controls, **not user authentication or DNS-rebinding protection**. Do not expose an unprotected dev/server instance to untrusted users or the public Internet; a public deployment needs an independent access/host gate. Remote ChatGPT callback routing, per-user accounts and credential import are not implemented.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Five layers, dependency arrows pointing inward only, machine-enforced as four ESLint zones (shell → barrels only; core and agent never import below themselves). Two composition roots, one writer per store. The full spec — layers, systems, runtime flows, invariants, seams: [`docs/architecture.md`](docs/architecture.md).
|
Five layers, dependency arrows pointing inward only, machine-enforced as four ESLint zones (shell → barrels only; core and agent never import below themselves). Two composition roots, one writer per store. The full spec — layers, systems, runtime flows, invariants, seams: [`docs/architecture.md`](docs/architecture.md).
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Tool-content presentation recovery
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
Model-authored tool content is parsed against its schema before presentation. Math or document recovery must not change persisted tool arguments, option identities, learner answers, shuffling, grading, or future answer snapshots. Those use the source-derived, schema-parsed content.
|
||||||
|
|
||||||
|
`ToolContentResponse` projects text through `repairMathForDisplay`, then uses the ordinary Markdown renderer. Teaching/probing questions, asks, recalls/explanations, and practice statements/solutions share this path. Ordinary assistant prose does not receive tool-content recovery.
|
||||||
|
|
||||||
|
There are three separate responsibilities:
|
||||||
|
|
||||||
|
1. **Argument/shape parsing:** decode encoded argument shapes, then schema conversion, validation and cleaning. No LaTeX recovery here.
|
||||||
|
2. **Content projection:** distinguish literal code, prose/document structure and math; recover only supported escaping defects.
|
||||||
|
3. **Presentation:** Markdown/KaTeX rendering, including paragraph layout. In labeled Why/answer reveals, only the first direct paragraph is inline with its label; subsequent paragraphs retain their normal layout.
|
||||||
|
|
||||||
|
## Recovery rules
|
||||||
|
|
||||||
|
### Literal code
|
||||||
|
|
||||||
|
Code and inline-code source ranges come from the rendering stack's CommonMark parser with its math extensions. This covers fences, indentation, quotes and lists without maintaining separate container heuristics. The projection copies those source ranges verbatim; it does not serialize an AST back into Markdown.
|
||||||
|
|
||||||
|
### Document escapes
|
||||||
|
|
||||||
|
A literal two-character `\n` is different from a physical newline. Document-layout recovery requires evidence such as repeated document-escape candidates or a candidate alongside over-escaped known TeX commands. Complete known n-prefixed TeX words, including `\nu`, `\nabla`, `\neq` and declared n-prefixed macros, take precedence over interpreting their prefix as document layout. Code, genuine TeX comments and recognized path tokens are protected.
|
||||||
|
|
||||||
|
Recovered double-dollar display framing puts the formula and closing fence on their own lines. Decoding the opening line alone can otherwise turn closing dollars into erroneous TeX content.
|
||||||
|
|
||||||
|
### JSON-swallowed command prefixes
|
||||||
|
|
||||||
|
Inside math, a control character can be the result of JSON consuming the start of a command: TAB + `ext` instead of `\text`, for example. Recovery reconstructs a command only if the control character plus the **complete following alphabetic word** names a known KaTeX command. All supported occurrences recover in one projection.
|
||||||
|
|
||||||
|
The owner-approved typical-model-authoring policy is:
|
||||||
|
|
||||||
|
- **Inline math:** recover recognized prefixes, including physical LF + `u` to `\nu`, and TAB + `o` to `\to`. There is no word-length cutoff.
|
||||||
|
- **Display math:** keep physical LF and CRLF as source formatting; recover recognized non-newline prefixes.
|
||||||
|
- **Correctly written commands:** remain commands in both modes. `\nu` is never decoded into LF + `u`.
|
||||||
|
- **Unrecognized whitespace:** remains unchanged. `x<TAB>+<TAB>y` does not become invented `\t`/`\ty` commands.
|
||||||
|
|
||||||
|
The control-character case is inherently ambiguous: whitespace followed by a variable may happen to spell a command. This policy targets typical model-authored LaTeX, not every syntactically possible human whitespace arrangement. TeX parse success alone cannot distinguish the two meanings.
|
||||||
|
|
||||||
|
### Extra command escaping, percentages and rows
|
||||||
|
|
||||||
|
Recovery examines the original TeX context before changing backslash runs:
|
||||||
|
|
||||||
|
- Complete native environment pairs and native `\substack` arguments preserve genuine row separators.
|
||||||
|
- Consistently extra-escaped environment pairs establish an escaping factor for commands **and rows**. For example, an encoded four-backslash row becomes two backslashes, not one.
|
||||||
|
- Outside protected native row/comment contexts, extra prefixes on known commands can recover. Unknown command words are not invented.
|
||||||
|
- Doubled percent escapes in ordinary numeric expressions can recover to `\%`, including numeric percentages at the end of a display formula. Genuine following comment text or equation rows remain literal. This is not a blanket `%` addition to an even-backslash rule.
|
||||||
|
- As an additional guard, partial command recovery does not turn parseable source into a TeX parse failure. Parse success is not the primary correctness test: both a corrupted matrix and a lost percentage can parse successfully.
|
||||||
|
|
||||||
|
Unbalanced, unsupported or ambiguous content is not generally corrected into new mathematics. This is an escaping recovery projection, not a general TeX authoring repair system. There are no language-specific decoders.
|
||||||
|
|
||||||
|
## Historical answers
|
||||||
|
|
||||||
|
`answerForPresentation` joins model-authored display fields to the original tool call without changing recorded answer facts. Option labels bind only through unambiguous selected values, exact/source-recovered labels, or one uniquely remaining pair in a complete bijection. Ambiguous rows keep their saved snapshots. Saved order, indices, selected values, verdicts, confidence and learner text are retained. Dismissed answers are unchanged. No migration or record rewrite is performed.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tests/content-recovery.test.ts`: recovery/preservation rules and adversarial row/comment/code examples.
|
||||||
|
- `tests/tool-content-presentation.test.tsx`: provenance-labeled synthetic corpus regressions, historical joins and ambiguity.
|
||||||
|
- `tests/tool-content-interaction.test.tsx`: select/submit/persist/reveal flow and paragraph structure.
|
||||||
|
- Existing Markdown, card, practice, tool-shape and grading suites remain applicable.
|
||||||
|
|
||||||
|
The local frozen corpus can be replayed offline with `scripts/replay-tool-rendering-corpus.ts`. Replay verifies source hashes and preserves baseline findings; it reports glyph loss, matrix rows, rendering errors, code bytes, schema regressions, answer facts and projection convergence. Corpus collection and larger diagnostic batches are separate from product recovery and are not prerequisites for running these unit tests.
|
||||||
+93
-21
@@ -24,12 +24,13 @@
|
|||||||
* would shadow every settings-page key change until restart.
|
* would shadow every settings-page key change until restart.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require("electron");
|
const { app, BrowserWindow, Menu, ipcMain, session, shell } = require("electron");
|
||||||
const { createChatGPT, fileStore } = require("./chatgpt.cjs");
|
const { createChatGPT, fileStore } = require("./chatgpt.cjs");
|
||||||
const { attachChatGPTChild, trustedSender, chatGPTControl } = require("./chatgpt-ipc.cjs");
|
const { attachChatGPTChild, trustedSender, chatGPTControl } = require("./chatgpt-ipc.cjs");
|
||||||
const { createSubscriptions, loadSubscriptionSDK } = require("./subscriptions.cjs");
|
const { createSubscriptions, loadSubscriptionSDK } = require("./subscriptions.cjs");
|
||||||
const { attachSubscriptionChild, subscriptionControl } = require("./subscription-ipc.cjs");
|
const { attachSubscriptionChild, subscriptionControl } = require("./subscription-ipc.cjs");
|
||||||
const { desktopAPIHeaders } = require("./desktop-api.cjs");
|
const { desktopAPIHeaders } = require("./desktop-api.cjs");
|
||||||
|
const { openWebSubscriptionBrowser } = require("./web-subscription-browser.cjs");
|
||||||
const { randomBytes } = require("node:crypto");
|
const { randomBytes } = require("node:crypto");
|
||||||
const { spawn } = require("node:child_process");
|
const { spawn } = require("node:child_process");
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
@@ -140,6 +141,7 @@ let subscriptions = null;
|
|||||||
let subscriptionChild = null;
|
let subscriptionChild = null;
|
||||||
// Per-launch request capability, never OAuth tokens and never renderer state.
|
// Per-launch request capability, never OAuth tokens and never renderer state.
|
||||||
let desktopAPISecret = null;
|
let desktopAPISecret = null;
|
||||||
|
let reopenWindow = null;
|
||||||
const trustedAppWindows = new Set();
|
const trustedAppWindows = new Set();
|
||||||
|
|
||||||
// A GUI shell must never die to a stray exception (EPIPE dialogs, a race
|
// A GUI shell must never die to a stray exception (EPIPE dialogs, a race
|
||||||
@@ -314,6 +316,7 @@ function createWindow(url) {
|
|||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
|
...(process.env.LH_DEV_URL ? { additionalArguments: ["--lh-web-subscriptions"] } : {}),
|
||||||
preload: path.join(__dirname, "preload.cjs"),
|
preload: path.join(__dirname, "preload.cjs"),
|
||||||
},
|
},
|
||||||
// Live-resize + first-paint flash (owner, 2026-09-17): during a resize
|
// Live-resize + first-paint flash (owner, 2026-09-17): during a resize
|
||||||
@@ -436,31 +439,57 @@ const ZOOM_LADDER = [0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2];
|
|||||||
* and before-quit alone would race the swap. */
|
* and before-quit alone would race the swap. */
|
||||||
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
let autoUpdater = null; // electron-updater's, packaged builds only
|
let autoUpdater = null; // electron-updater's, packaged builds only
|
||||||
|
// Observational only: this snapshot NEVER gates a check, download or install.
|
||||||
|
// The revision lets late subscribers reject an older in-flight IPC snapshot.
|
||||||
|
let updateState = { status: "idle", version: null, revision: 0 };
|
||||||
|
|
||||||
function broadcastUpdateStatus(status) {
|
function reportUpdateStatus(status, version = null) {
|
||||||
for (const win of BrowserWindow.getAllWindows()) {
|
try {
|
||||||
try {
|
// A later check/error must not hide an update already staged for quit.
|
||||||
win.webContents.send("lh:update-status", status);
|
if (updateState.status === "downloaded" && status !== "downloaded") return;
|
||||||
} catch {
|
updateState = { status, version: typeof version === "string" ? version : null, revision: updateState.revision + 1 };
|
||||||
/* window mid-teardown — the next push retries */
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
|
try {
|
||||||
|
win.webContents.send("lh:update-status", updateState);
|
||||||
|
} catch {
|
||||||
|
/* window mid-teardown — snapshot retrieval covers a later load */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
/* Status reporting must never interrupt the updater. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the SAME data directory passed to the packaged server. Only an
|
||||||
|
* explicit false opts out; absent/corrupt/unreadable settings keep updates ON.
|
||||||
|
* Re-read for each automatic check so saving the toggle needs no restart. */
|
||||||
|
function automaticUpdatesEnabled() {
|
||||||
|
try {
|
||||||
|
const settings = JSON.parse(fs.readFileSync(path.join(DATA_DIR(), "settings.json"), "utf8"));
|
||||||
|
return settings?.general?.autoUpdate !== false;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkForUpdates({ manual = false } = {}) {
|
function checkForUpdates({ manual = false } = {}) {
|
||||||
if (!autoUpdater) return;
|
if (!autoUpdater) return;
|
||||||
|
if (!manual && !automaticUpdatesEnabled()) {
|
||||||
|
log("auto-update: automatic check skipped (turned off in Settings)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Do not gate on display state: a stale spinner/error must never disable
|
||||||
|
// future background checks. electron-updater owns its in-flight work.
|
||||||
autoUpdater
|
autoUpdater
|
||||||
.checkForUpdates()
|
.checkForUpdates()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
// The download itself is silent (autoDownload); the banner fires on
|
// A declined check (e.g. an unsupported package) establishes no
|
||||||
// update-downloaded. Manual checks additionally answer directly.
|
// latest-version claim. Real results arrive through updater events.
|
||||||
if (!manual) return;
|
if (!result) reportUpdateStatus("idle");
|
||||||
const version = result?.updateInfo?.version;
|
|
||||||
if (!version) dialog.showMessageBox({ type: "info", message: "You're up to date.", detail: `LearnHarness ${app.getVersion()} is the latest version.` });
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
log(`update check failed: ${err?.message ?? err}`);
|
log(`update check failed: ${err?.message ?? err}`);
|
||||||
if (manual) dialog.showMessageBox({ type: "warning", message: "Couldn't check for updates.", detail: String(err?.message ?? err) });
|
reportUpdateStatus("error");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,12 +506,14 @@ function installUpdateNow() {
|
|||||||
function setupAutoUpdater() {
|
function setupAutoUpdater() {
|
||||||
if (!app.isPackaged) {
|
if (!app.isPackaged) {
|
||||||
log("auto-update: dev build — updater disabled");
|
log("auto-update: dev build — updater disabled");
|
||||||
|
reportUpdateStatus("disabled");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
autoUpdater = require("electron-updater").autoUpdater;
|
autoUpdater = require("electron-updater").autoUpdater;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`auto-update: electron-updater unavailable (${err?.message ?? err}) — updates disabled`);
|
log(`auto-update: electron-updater unavailable (${err?.message ?? err}) — updates disabled`);
|
||||||
|
reportUpdateStatus("disabled");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const feed = process.env.LH_UPDATE_FEED;
|
const feed = process.env.LH_UPDATE_FEED;
|
||||||
@@ -500,11 +531,37 @@ function setupAutoUpdater() {
|
|||||||
debug: (m) => {}, // too chatty for the persistent log
|
debug: (m) => {}, // too chatty for the persistent log
|
||||||
error: (m) => log(`updater error: ${m}`),
|
error: (m) => log(`updater error: ${m}`),
|
||||||
};
|
};
|
||||||
|
autoUpdater.on("checking-for-update", () => reportUpdateStatus("checking"));
|
||||||
|
autoUpdater.on("update-available", (info) => reportUpdateStatus("downloading", info?.version));
|
||||||
|
autoUpdater.on("update-not-available", () => reportUpdateStatus("up-to-date"));
|
||||||
autoUpdater.on("update-downloaded", (info) => {
|
autoUpdater.on("update-downloaded", (info) => {
|
||||||
log(`update downloaded: ${info?.version} — banner shown`);
|
log(`update downloaded: ${info?.version} — banner shown`);
|
||||||
broadcastUpdateStatus({ status: "downloaded", version: info?.version ?? null });
|
reportUpdateStatus("downloaded", info?.version);
|
||||||
});
|
});
|
||||||
autoUpdater.on("error", (err) => log(`updater error: ${err?.message ?? err}`));
|
autoUpdater.on("error", (err) => {
|
||||||
|
log(`updater error: ${err?.message ?? err}`);
|
||||||
|
reportUpdateStatus("error");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One result surface for manual checks. Preload buffers the navigation
|
||||||
|
* request until React subscribes, including when all macOS windows closed. */
|
||||||
|
function checkForUpdatesFromMenu() {
|
||||||
|
try {
|
||||||
|
const win = BrowserWindow.getAllWindows()[0] ?? reopenWindow?.();
|
||||||
|
if (win) {
|
||||||
|
if (win.isMinimized()) win.restore();
|
||||||
|
win.focus();
|
||||||
|
const openSettings = () => {
|
||||||
|
try { win.webContents.send("lh:open-settings", "general"); } catch { /* window closed */ }
|
||||||
|
};
|
||||||
|
if (win.webContents.isLoadingMainFrame()) win.webContents.once("did-finish-load", openSettings);
|
||||||
|
else openSettings();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* Navigation failure must not prevent the manual check. */
|
||||||
|
}
|
||||||
|
checkForUpdates({ manual: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function zoomStep(win, dir) {
|
function zoomStep(win, dir) {
|
||||||
@@ -530,9 +587,8 @@ function installMenu() {
|
|||||||
label: app.name,
|
label: app.name,
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "about" },
|
{ role: "about" },
|
||||||
// Manual check (Wave C): answers via native dialog — no renderer
|
// Manual checks report their result in Settings → General.
|
||||||
// round-trip. The 24h automatic checks stay silent.
|
{ label: "Check for Updates…", click: () => checkForUpdatesFromMenu() },
|
||||||
{ label: "Check for Updates…", click: () => checkForUpdates({ manual: true }) },
|
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "hide" },
|
{ role: "hide" },
|
||||||
{ role: "hideOthers" },
|
{ role: "hideOthers" },
|
||||||
@@ -646,6 +702,13 @@ async function main() {
|
|||||||
return subscriptionControl(command, subscriptions);
|
return subscriptionControl(command, subscriptions);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The dev shell has no browser tabs. Only a one-use action on its own
|
||||||
|
// Next server may be resolved here; never accept an arbitrary external URL.
|
||||||
|
ipcMain.handle("lh:subscriptions:web-browser", async (event, action) => {
|
||||||
|
if (!devUrl || !trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted development request.");
|
||||||
|
await openWebSubscriptionBrowser(action, new URL(url).origin, { openExternal: (target) => shell.openExternal(target) });
|
||||||
|
});
|
||||||
|
|
||||||
installMenu();
|
installMenu();
|
||||||
|
|
||||||
// Auto-update (Wave C): wire the updater before the window so the
|
// Auto-update (Wave C): wire the updater before the window so the
|
||||||
@@ -654,6 +717,14 @@ async function main() {
|
|||||||
// are log lines — this can never block boot.
|
// are log lines — this can never block boot.
|
||||||
setupAutoUpdater();
|
setupAutoUpdater();
|
||||||
ipcMain.on("lh:update-install", () => installUpdateNow());
|
ipcMain.on("lh:update-install", () => installUpdateNow());
|
||||||
|
ipcMain.handle("lh:update:get", (event) => {
|
||||||
|
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted desktop request.");
|
||||||
|
return updateState;
|
||||||
|
});
|
||||||
|
ipcMain.handle("lh:update:check", (event) => {
|
||||||
|
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted desktop request.");
|
||||||
|
checkForUpdates({ manual: true });
|
||||||
|
});
|
||||||
|
|
||||||
// Sidebar-at-narrow-window (owner, 2026-09-17): the renderer auto-closes
|
// Sidebar-at-narrow-window (owner, 2026-09-17): the renderer auto-closes
|
||||||
// the sidebar below SIDEBAR_AUTOCLOSE (928px); opening it again at that
|
// the sidebar below SIDEBAR_AUTOCLOSE (928px); opening it again at that
|
||||||
@@ -670,13 +741,14 @@ async function main() {
|
|||||||
const target = SIDEBAR_AUTOCLOSE;
|
const target = SIDEBAR_AUTOCLOSE;
|
||||||
if (width < target) win.setBounds({ x, y, width: target, height });
|
if (width < target) win.setBounds({ x, y, width: target, height });
|
||||||
});
|
});
|
||||||
|
reopenWindow = () => { chatgpt?.resume(); subscriptions?.resume(); return createWindow(url); };
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) { chatgpt?.resume(); subscriptions?.resume(); createWindow(url); }
|
if (BrowserWindow.getAllWindows().length === 0) reopenWindow();
|
||||||
});
|
});
|
||||||
createWindow(url);
|
createWindow(url);
|
||||||
log(`window open: ${url}`);
|
log(`window open: ${url}`);
|
||||||
// First update check AFTER the window exists (the downloaded-banner
|
// Startup + 24h checks stay main-owned and read the current preference.
|
||||||
// push needs a listener); dev builds no-op (no autoUpdater).
|
// No renderer subscription is required; late loads retrieve the snapshot.
|
||||||
if (autoUpdater) {
|
if (autoUpdater) {
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
setInterval(() => checkForUpdates(), UPDATE_CHECK_INTERVAL_MS);
|
setInterval(() => checkForUpdates(), UPDATE_CHECK_INTERVAL_MS);
|
||||||
|
|||||||
+31
-3
@@ -19,16 +19,31 @@
|
|||||||
*
|
*
|
||||||
* window.lh.onUpdateStatus(cb) / window.lh.installUpdate() — the
|
* window.lh.onUpdateStatus(cb) / window.lh.installUpdate() — the
|
||||||
* auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes
|
* auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes
|
||||||
* {status:"downloaded", version} once an update is staged; installUpdate
|
* {status, version, revision} on updater events; installUpdate asks main
|
||||||
* asks main to kill the server and quit-and-install. Browser builds:
|
* to kill the server and quit-and-install. getUpdateState retrieves the
|
||||||
* both absent, the banner never renders.
|
* snapshot for a late subscriber; checkForUpdates requests a manual
|
||||||
|
* check (including download/install-on-quit, even with automatic checks OFF).
|
||||||
|
*
|
||||||
|
* window.lh.onOpenSettings(cb) — menu navigation, buffered until React
|
||||||
|
* subscribes after a window is reopened. Browser builds have no bridge.
|
||||||
*
|
*
|
||||||
* Channel names are mirrored in electron/main.cjs — keep them in sync.
|
* Channel names are mirrored in electron/main.cjs — keep them in sync.
|
||||||
*/
|
*/
|
||||||
const { contextBridge, ipcRenderer } = require("electron");
|
const { contextBridge, ipcRenderer } = require("electron");
|
||||||
|
|
||||||
|
let openSettingsListener = null;
|
||||||
|
let pendingOpenSettings = null;
|
||||||
|
ipcRenderer.on("lh:open-settings", (_event, section) => {
|
||||||
|
if (openSettingsListener) openSettingsListener(section);
|
||||||
|
else pendingOpenSettings = section;
|
||||||
|
});
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("lh", {
|
contextBridge.exposeInMainWorld("lh", {
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
|
...(process.argv?.includes("--lh-web-subscriptions") ? {
|
||||||
|
subscriptionTransport: "web",
|
||||||
|
openSubscriptionBrowser: (action) => ipcRenderer.invoke("lh:subscriptions:web-browser", action),
|
||||||
|
} : {}),
|
||||||
chatgpt: {
|
chatgpt: {
|
||||||
status: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "status" }),
|
status: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "status" }),
|
||||||
login: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "login", id }),
|
login: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "login", id }),
|
||||||
@@ -71,4 +86,17 @@ contextBridge.exposeInMainWorld("lh", {
|
|||||||
return () => ipcRenderer.removeListener("lh:update-status", listener);
|
return () => ipcRenderer.removeListener("lh:update-status", listener);
|
||||||
},
|
},
|
||||||
installUpdate: () => ipcRenderer.send("lh:update-install"),
|
installUpdate: () => ipcRenderer.send("lh:update-install"),
|
||||||
|
getUpdateState: () => ipcRenderer.invoke("lh:update:get"),
|
||||||
|
checkForUpdates: () => ipcRenderer.invoke("lh:update:check"),
|
||||||
|
onOpenSettings: (cb) => {
|
||||||
|
openSettingsListener = cb;
|
||||||
|
if (pendingOpenSettings !== null) {
|
||||||
|
const section = pendingOpenSettings;
|
||||||
|
pendingOpenSettings = null;
|
||||||
|
cb(section);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (openSettingsListener === cb) openSettingsListener = null;
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/** Desktop-dev has no tabs. Resolve only an opaque action on its own Next
|
||||||
|
* server, then open the owner-validated provider page outside the renderer.
|
||||||
|
* The caller must authenticate its trusted app main frame first. */
|
||||||
|
async function openWebSubscriptionBrowser(action, origin, { fetch: request = globalThis.fetch, openExternal }) {
|
||||||
|
if (typeof action !== "string" || !/^\/api\/subscriptions\/browser\?id=[0-9a-f-]{36}$/.test(action)) throw new Error("Invalid sign-in action.");
|
||||||
|
const response = await request(new URL(action, origin), {
|
||||||
|
method: "POST", redirect: "manual", credentials: "omit",
|
||||||
|
headers: { origin, "x-learnharness-subscriptions": "1" },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
const target = response.headers.get("location");
|
||||||
|
if (response.status !== 303 || !target) throw new Error("Sign-in action expired.");
|
||||||
|
const parsed = new URL(target);
|
||||||
|
if (parsed.protocol !== "https:" || parsed.username || parsed.password) throw new Error("Invalid provider page.");
|
||||||
|
await openExternal(target);
|
||||||
|
}
|
||||||
|
module.exports = { openWebSubscriptionBrowser };
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")) as { version: string; homepage: string };
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
// Public build metadata only; no new runtime Electron version API.
|
||||||
|
env: { LH_VERSION: pkg.version, LH_HOMEPAGE: pkg.homepage },
|
||||||
// Desktop target (Electron wrapper, 2026-09-17): standalone output = a
|
// Desktop target (Electron wrapper, 2026-09-17): standalone output = a
|
||||||
// self-contained .next/standalone/server.js the Electron shell spawns
|
// self-contained .next/standalone/server.js the Electron shell spawns
|
||||||
// on 127.0.0.1. Harmless for plain web deploys (extra build output).
|
// on 127.0.0.1. Harmless for plain web deploys (extra build output).
|
||||||
@@ -43,6 +48,9 @@ const nextConfig: NextConfig = {
|
|||||||
"*": [
|
"*": [
|
||||||
"**/data/**/*",
|
"**/data/**/*",
|
||||||
"auth.json",
|
"auth.json",
|
||||||
|
"chatgpt-credentials.json*",
|
||||||
|
"github-copilot-credentials.json*",
|
||||||
|
"xai-credentials.json*",
|
||||||
".env*",
|
".env*",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+9
-6
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.3.7",
|
"version": "0.3.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.3.7",
|
"version": "0.3.8",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-agent-core": "1.4.0",
|
"@earendil-works/pi-agent-core": "1.4.0",
|
||||||
@@ -35,7 +35,10 @@
|
|||||||
"katex": "0.18.2",
|
"katex": "0.18.2",
|
||||||
"linkedom": "^0.18.13",
|
"linkedom": "^0.18.13",
|
||||||
"lucide-react": "^1.33.0",
|
"lucide-react": "^1.33.0",
|
||||||
|
"mdast-util-from-markdown": "2.0.3",
|
||||||
|
"mdast-util-math": "3.0.0",
|
||||||
"media-chrome": "^4.19.2",
|
"media-chrome": "^4.19.2",
|
||||||
|
"micromark-extension-math": "3.1.0",
|
||||||
"mermaid": "^11.17.0",
|
"mermaid": "^11.17.0",
|
||||||
"motion": "^13.1.1",
|
"motion": "^13.1.1",
|
||||||
"nanoid": "^6.0.1",
|
"nanoid": "^6.0.1",
|
||||||
@@ -65,7 +68,7 @@
|
|||||||
"@types/turndown": "^5.0.6",
|
"@types/turndown": "^5.0.6",
|
||||||
"@typescript-eslint/parser": "^8.68.0",
|
"@typescript-eslint/parser": "^8.68.0",
|
||||||
"concurrently": "^10.0.5",
|
"concurrently": "^10.0.5",
|
||||||
"electron": "^44.4.1",
|
"electron": "^44.9.3",
|
||||||
"electron-builder": "26.16.1",
|
"electron-builder": "26.16.1",
|
||||||
"eslint": "^9.39.5",
|
"eslint": "^9.39.5",
|
||||||
"eslint-import-resolver-typescript": "^4.4.5",
|
"eslint-import-resolver-typescript": "^4.4.5",
|
||||||
@@ -8687,9 +8690,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron": {
|
"node_modules/electron": {
|
||||||
"version": "44.4.1",
|
"version": "44.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/electron/-/electron-44.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/electron/-/electron-44.9.3.tgz",
|
||||||
"integrity": "sha512-gXJarQ2Utfn8G6bp/IqLXvckOSCtrxPWjFqQaqm02Gf4zYn1aP+RN9PKimoLH/DMqjKLfBrNC4C4CviPv0cXnQ==",
|
"integrity": "sha512-Cv9/yMnZxNWsMlIe8T4v//NKHdUvbeyoTJoXE3BIcRxus2meeFaO9h/P3zB5d4cBXum51IgAYrT2QjpIDWKpJA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.3.7",
|
"version": "0.3.8",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Amos Blomqvist",
|
"name": "Amos Blomqvist",
|
||||||
@@ -50,7 +50,10 @@
|
|||||||
"katex": "0.18.2",
|
"katex": "0.18.2",
|
||||||
"linkedom": "^0.18.13",
|
"linkedom": "^0.18.13",
|
||||||
"lucide-react": "^1.33.0",
|
"lucide-react": "^1.33.0",
|
||||||
|
"mdast-util-from-markdown": "2.0.3",
|
||||||
|
"mdast-util-math": "3.0.0",
|
||||||
"media-chrome": "^4.19.2",
|
"media-chrome": "^4.19.2",
|
||||||
|
"micromark-extension-math": "3.1.0",
|
||||||
"mermaid": "^11.17.0",
|
"mermaid": "^11.17.0",
|
||||||
"motion": "^13.1.1",
|
"motion": "^13.1.1",
|
||||||
"nanoid": "^6.0.1",
|
"nanoid": "^6.0.1",
|
||||||
@@ -83,7 +86,7 @@
|
|||||||
"@types/turndown": "^5.0.6",
|
"@types/turndown": "^5.0.6",
|
||||||
"@typescript-eslint/parser": "^8.68.0",
|
"@typescript-eslint/parser": "^8.68.0",
|
||||||
"concurrently": "^10.0.5",
|
"concurrently": "^10.0.5",
|
||||||
"electron": "^44.4.1",
|
"electron": "^44.9.3",
|
||||||
"electron-builder": "26.16.1",
|
"electron-builder": "26.16.1",
|
||||||
"eslint": "^9.39.5",
|
"eslint": "^9.39.5",
|
||||||
"eslint-import-resolver-typescript": "^4.4.5",
|
"eslint-import-resolver-typescript": "^4.4.5",
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ if (exists(leakedData)) {
|
|||||||
// (the DEV BYOK key store, 0600) byte-identical into the DMG (caught in
|
// (the DEV BYOK key store, 0600) byte-identical into the DMG (caught in
|
||||||
// the 2026-10-08 payload audit; excluded in next.config.ts — this guard
|
// the 2026-10-08 payload audit; excluded in next.config.ts — this guard
|
||||||
// makes a regression loud instead of silent).
|
// makes a regression loud instead of silent).
|
||||||
for (const secret of ["auth.json", ".env", ".env.local"]) {
|
const subscriptionSecrets = fs.readdirSync(STANDALONE).filter((name) => /^(chatgpt|github-copilot|xai)-credentials\.json/.test(name));
|
||||||
|
for (const secret of ["auth.json", ...subscriptionSecrets, ".env", ".env.local"]) {
|
||||||
if (exists(path.join(STANDALONE, secret))) {
|
if (exists(path.join(STANDALONE, secret))) {
|
||||||
console.error(`[build-server] REFUSING: standalone contains ${secret} — dev secrets would ship in the app.`);
|
console.error(`[build-server] REFUSING: standalone contains ${secret} — dev secrets would ship in the app.`);
|
||||||
console.error(" Check outputFileTracingExcludes in next.config.ts.");
|
console.error(" Check outputFileTracingExcludes in next.config.ts.");
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/** Offline Next runtime gate (run after next build, with no dev server running).
|
||||||
|
* Proves cold-start registration, cross-route owner/events and dev reloads using
|
||||||
|
* synthetic credentials in a fresh profile. No OAuth, provider traffic or real
|
||||||
|
* auth/data. The dev reload changes only a source mtime, restoring it afterward.
|
||||||
|
* This is a test, not an alternative app launcher. */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { createServer } from "node:net";
|
||||||
|
|
||||||
|
const ROOT = path.resolve(import.meta.dirname, "..");
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { SCOPES } = require("../electron/chatgpt.cjs");
|
||||||
|
const sdk = (file) => import(pathToFileURL(path.join(ROOT, "node_modules/@earendil-works/pi-ai/dist", file)));
|
||||||
|
const { createModels } = await sdk("index.js");
|
||||||
|
const { builtinProviders } = await sdk("providers/all.js");
|
||||||
|
const { githubCopilotProvider } = await sdk("providers/github-copilot.js");
|
||||||
|
const models = createModels();
|
||||||
|
for (const provider of builtinProviders()) models.setProvider(provider);
|
||||||
|
const model = models.getModels("openai").find((entry) => entry.api === "openai-responses");
|
||||||
|
const copilot = githubCopilotProvider().getModels()[0];
|
||||||
|
assert(model && copilot, "Installed SDK catalogs required.");
|
||||||
|
|
||||||
|
const profile = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-runtime-"));
|
||||||
|
await fs.chmod(profile, 0o700);
|
||||||
|
for (const name of ["home", "config", "cache", "xdg-data", "data", "pi", "claude", "codex", "tmp"]) await fs.mkdir(path.join(profile, name), { mode: 0o700 });
|
||||||
|
await fs.writeFile(path.join(profile, "auth.json"), "{}\n", { mode: 0o600 });
|
||||||
|
const preload = path.join(profile, "offline-fetch.mjs");
|
||||||
|
await fs.writeFile(preload, `const original = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
|
||||||
|
if (url.href === 'https://api.openai.com/v1/models') return Response.json({ models: [{ slug: ${JSON.stringify(model.id)}, display_name: 'Offline fixture ChatGPT', visibility: 'list' }] });
|
||||||
|
if (['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) return original(input, init);
|
||||||
|
throw new Error('Offline subscription gate forbids external network: ' + url.origin + url.pathname);
|
||||||
|
};\n`, { mode: 0o600 });
|
||||||
|
// Intentionally an allowlist, not a copy of the calling shell's environment.
|
||||||
|
const env = {
|
||||||
|
PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: path.join(profile, "home"),
|
||||||
|
XDG_CONFIG_HOME: path.join(profile, "config"), XDG_CACHE_HOME: path.join(profile, "cache"), XDG_DATA_HOME: path.join(profile, "xdg-data"),
|
||||||
|
PI_CODING_AGENT_DIR: path.join(profile, "pi"), PI_AGENT_DIR: path.join(profile, "pi"), CLAUDE_CONFIG_DIR: path.join(profile, "claude"), CODEX_HOME: path.join(profile, "codex"),
|
||||||
|
TMPDIR: path.join(profile, "tmp"), LH_AUTH_FILE: path.join(profile, "auth.json"), LH_DATA_DIR: path.join(profile, "data"),
|
||||||
|
NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`, NEXT_TELEMETRY_DISABLED: "1",
|
||||||
|
};
|
||||||
|
const saved = {
|
||||||
|
version: 1, hostId: "offline-host", allowBackgroundUse: true, activeId: "offline-account",
|
||||||
|
registrations: [{ id: "offline-account", clientId: "oaiapp_fixture", issuer: "https://auth.openai.com", subject: "offline-subject", email: "fixture@example.invalid", tokens: { accessToken: "fixture-chatgpt-access", refreshToken: "fixture-chatgpt-refresh", idToken: "fixture-id-token", scopes: SCOPES, expiresAt: Date.now() + 3600000 } }],
|
||||||
|
};
|
||||||
|
function timeout(promise, milliseconds, message) {
|
||||||
|
let timer;
|
||||||
|
return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), milliseconds); })]).finally(() => clearTimeout(timer));
|
||||||
|
}
|
||||||
|
async function freePort() {
|
||||||
|
const socket = createServer();
|
||||||
|
await new Promise((resolve, reject) => { socket.once("error", reject); socket.listen(0, "127.0.0.1", resolve); });
|
||||||
|
const { port } = socket.address(); await new Promise((resolve) => socket.close(resolve)); return port;
|
||||||
|
}
|
||||||
|
async function run(mode) {
|
||||||
|
await fs.writeFile(path.join(profile, "chatgpt-credentials.json"), JSON.stringify(saved), { mode: 0o600 });
|
||||||
|
for (const id of ["github-copilot", "xai"]) await fs.writeFile(path.join(profile, `${id}-credentials.json`), JSON.stringify({
|
||||||
|
version: 1, allowBackgroundUse: true, connectionId: "00000000-0000-4000-8000-000000000001",
|
||||||
|
credential: { type: "oauth", access: id === "xai" ? "fixture-xai-access" : "tid=fixture;proxy-ep=proxy.individual.githubcopilot.com;", refresh: "fixture-refresh", expires: Date.now() + 3600000, ...(id === "github-copilot" ? { availableModelIds: [copilot.id] } : {}) },
|
||||||
|
}), { mode: 0o600 });
|
||||||
|
const port = await freePort(), origin = `http://localhost:${port}`;
|
||||||
|
let logs = "", exited = false;
|
||||||
|
const child = spawn(process.execPath, [path.join(ROOT, "node_modules/next/dist/bin/next"), mode, "-p", String(port)], { cwd: ROOT, env, stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
const exit = new Promise((resolve) => child.once("exit", () => { exited = true; resolve(); }));
|
||||||
|
try {
|
||||||
|
await timeout(new Promise((resolve, reject) => {
|
||||||
|
const receive = (data) => { logs = (logs + data).slice(-16000); if (logs.includes("Ready in")) resolve(); };
|
||||||
|
child.stdout.on("data", receive); child.stderr.on("data", receive);
|
||||||
|
child.once("error", reject); child.once("exit", (code) => reject(new Error(`Next ${mode} exited ${code}.`)));
|
||||||
|
}), 60000, `Next ${mode} readiness timeout.`);
|
||||||
|
const request = (url, command) => fetch(origin + url, {
|
||||||
|
...(command ? { method: "POST", body: JSON.stringify(command) } : {}),
|
||||||
|
headers: { origin, "sec-fetch-site": "same-origin", "x-learnharness-subscriptions": "1", "content-type": "application/json" },
|
||||||
|
signal: AbortSignal.timeout(60000),
|
||||||
|
});
|
||||||
|
// Controls have not been imported through an HTTP request yet.
|
||||||
|
const catalog = (await (await request("/api/models")).json()).providers;
|
||||||
|
for (const id of ["chatgpt", "github-copilot-subscription", "xai-subscription"]) assert(catalog.find((provider) => provider.id === id)?.keyPresent, `${mode}: cold ${id} unavailable.`);
|
||||||
|
let state = await (await request("/api/subscriptions")).json();
|
||||||
|
assert.equal(state.chatgpt.accounts[0].email, "fixture@example.invalid"); assert.equal(state.native.length, 2);
|
||||||
|
for (const secret of ["fixture-chatgpt-access", "fixture-chatgpt-refresh", "fixture-id-token", "fixture-xai-access", "fixture-refresh"]) assert(!JSON.stringify(state).includes(secret));
|
||||||
|
const stream = await fetch(origin + "/api/events", { signal: AbortSignal.timeout(60000) });
|
||||||
|
const reader = stream.body.getReader();
|
||||||
|
try {
|
||||||
|
const first = await reader.read(); assert(first.value);
|
||||||
|
assert.equal((await request("/api/subscriptions", { providerId: "chatgpt", op: "consent", enabled: false })).status, 200);
|
||||||
|
let events = new TextDecoder().decode(first.value);
|
||||||
|
while (!events.includes("subscriptions-changed")) {
|
||||||
|
const frame = await timeout(reader.read(), 5000, "Cross-route subscription SSE timeout.");
|
||||||
|
assert(!frame.done, "Subscription event stream ended early."); events += new TextDecoder().decode(frame.value);
|
||||||
|
}
|
||||||
|
} finally { await reader.cancel(); }
|
||||||
|
state = await (await request("/api/subscriptions")).json(); assert.equal(state.chatgpt.allowBackgroundUse, false);
|
||||||
|
const login = await (await request("/api/subscriptions", { providerId: "chatgpt", op: "login" })).json();
|
||||||
|
assert(login.browserAction); assert(!JSON.stringify(login).includes("auth.openai.com"));
|
||||||
|
if (mode === "dev") {
|
||||||
|
const file = path.join(ROOT, "src/server/web-subscriptions.ts"), times = await fs.stat(file);
|
||||||
|
await fs.utimes(file, new Date(), new Date());
|
||||||
|
try { assert.equal((await request("/api/models")).status, 200); assert.equal((await request("/api/subscriptions")).status, 200); }
|
||||||
|
finally { await fs.utimes(file, times.atime, times.mtime); }
|
||||||
|
}
|
||||||
|
const navigate = () => fetch(origin + login.browserAction, { redirect: "manual", headers: { "sec-fetch-site": "same-origin" }, signal: AbortSignal.timeout(30000) });
|
||||||
|
const browser = await navigate(); assert.equal(browser.status, 303, `${mode}: owner/action lost across routes or reload.`);
|
||||||
|
const location = new URL(browser.headers.get("location")); assert.equal(location.origin, "https://auth.openai.com"); assert.equal(new URL(location.searchParams.get("redirect_uri")).hostname, "127.0.0.1");
|
||||||
|
assert.equal((await navigate()).status, 410);
|
||||||
|
assert.equal((await request("/api/subscriptions", { providerId: "chatgpt", op: "cancel" })).status, 200);
|
||||||
|
assert.equal((await fetch(origin + "/api/subscriptions", { method: "POST", headers: { origin: "https://unrelated.invalid", "content-type": "application/json", "x-learnharness-subscriptions": "1" }, body: JSON.stringify({ providerId: "chatgpt", op: "disconnect", id: "offline-account" }) })).status, 403);
|
||||||
|
assert.equal((await request("/api/subscriptions", { providerId: "chatgpt", op: "disconnect", id: "offline-account" })).status, 200);
|
||||||
|
assert.equal((await (await request("/api/models")).json()).providers.find((provider) => provider.id === "chatgpt").keyPresent, false);
|
||||||
|
console.log(`[smoke-web-subscriptions] ${mode}: cold catalogs, safe status, cross-route SSE/consent/disconnect, opaque redirect/replay, CSRF${mode === "dev" ? ", owner retained across source reload" : ""} passed`);
|
||||||
|
} catch (error) { console.error(logs); throw error; }
|
||||||
|
finally {
|
||||||
|
if (!exited) child.kill("SIGTERM");
|
||||||
|
try { await timeout(exit, 5000, "Next shutdown timeout."); }
|
||||||
|
catch { child.kill("SIGKILL"); await exit; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try { await run("start"); await run("dev"); }
|
||||||
|
finally { await fs.rm(profile, { recursive: true, force: true }); }
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Model } from "@earendil-works/pi-ai";
|
import type { Model } from "@earendil-works/pi-ai";
|
||||||
|
import { onWebSubscriptionPacket, webSubscriptionRequest, webSubscriptionsEnabled } from "./subscription-runtime.ts";
|
||||||
import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
|
import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
|
||||||
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
|
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
|
||||||
export type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
|
export type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
|
||||||
@@ -20,14 +21,15 @@ const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision]
|
|||||||
function desktopIPCConnected(): boolean {
|
function desktopIPCConnected(): boolean {
|
||||||
return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true;
|
return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true;
|
||||||
}
|
}
|
||||||
|
function runtimeConnected(): boolean { return desktopIPCConnected() || webSubscriptionsEnabled(); }
|
||||||
function bridge(): BridgeState {
|
function bridge(): BridgeState {
|
||||||
const global = globalThis as typeof globalThis & { [key]?: BridgeState };
|
const global = globalThis as typeof globalThis & { [key]?: BridgeState };
|
||||||
if (!global[key]) {
|
if (!global[key]) {
|
||||||
const state: BridgeState = { status: unavailable(), stamp: { epoch: 0, authorityRevision: 0, revision: 0 }, models: [], catalogAccount: null, pending: new Map(), controllers: new Map(), listeners: new Set(), counter: 0, catalogRequest: 0 };
|
const state: BridgeState = { status: unavailable(), stamp: { epoch: 0, authorityRevision: 0, revision: 0 }, models: [], catalogAccount: null, pending: new Map(), controllers: new Map(), listeners: new Set(), counter: 0, catalogRequest: 0 };
|
||||||
global[key] = state;
|
global[key] = state;
|
||||||
process.on("message", (raw: unknown) => {
|
const receive = (raw: unknown) => {
|
||||||
const m = raw as { channel?: string; kind?: string; id?: string; op?: string; ok?: boolean; value?: unknown; stamp: Stamp; status: ChatGPTStatus; invalidate?: string; error?: string };
|
const m = raw as { channel?: string; kind?: string; id?: string; op?: string; ok?: boolean; value?: unknown; stamp: Stamp; status: ChatGPTStatus; invalidate?: string; error?: string };
|
||||||
if (!desktopIPCConnected() || m?.channel !== "lh:chatgpt") return;
|
if (m?.channel !== "lh:chatgpt") return;
|
||||||
if (m.kind === "state" && validStamp(m.stamp) && m.stamp.revision >= state.stamp.revision) {
|
if (m.kind === "state" && validStamp(m.stamp) && m.stamp.revision >= state.stamp.revision) {
|
||||||
acceptStatus(state, { ...m.stamp, status: m.status });
|
acceptStatus(state, { ...m.stamp, status: m.status });
|
||||||
if (m.invalidate === "all") { state.catalogRequest++; clearCatalog(state); }
|
if (m.invalidate === "all") { state.catalogRequest++; clearCatalog(state); }
|
||||||
@@ -43,8 +45,11 @@ function bridge(): BridgeState {
|
|||||||
if (m.ok === true) call.resolve(m.value);
|
if (m.ok === true) call.resolve(m.value);
|
||||||
else call.reject(new Error(m.error ?? "ChatGPT connection failed."));
|
else call.reject(new Error(m.error ?? "ChatGPT connection failed."));
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
process.on("message", (raw: unknown) => { if (desktopIPCConnected()) receive(raw); });
|
||||||
|
onWebSubscriptionPacket("lh:chatgpt", (packet) => { if (webSubscriptionsEnabled()) receive(packet); });
|
||||||
process.on("disconnect", () => {
|
process.on("disconnect", () => {
|
||||||
|
if (webSubscriptionsEnabled()) return;
|
||||||
state.status = unavailable(); clearCatalog(state); state.catalogRequest++;
|
state.status = unavailable(); clearCatalog(state); state.catalogRequest++;
|
||||||
for (const controller of state.controllers.keys()) controller.abort("all");
|
for (const controller of state.controllers.keys()) controller.abort("all");
|
||||||
for (const call of state.pending.values()) { clearTimeout(call.timer); call.reject(new Error("The desktop connection closed.")); }
|
for (const call of state.pending.values()) { clearTimeout(call.timer); call.reject(new Error("The desktop connection closed.")); }
|
||||||
@@ -60,9 +65,10 @@ function acceptStatus(state: BridgeState, value: StatusValue) {
|
|||||||
if (value.epoch !== state.stamp.epoch || value.authorityRevision !== state.stamp.authorityRevision || value.status.activeId !== state.status.activeId) clearCatalog(state);
|
if (value.epoch !== state.stamp.epoch || value.authorityRevision !== state.stamp.authorityRevision || value.status.activeId !== state.status.activeId) clearCatalog(state);
|
||||||
state.status = value.status; state.stamp = { epoch: value.epoch, authorityRevision: value.authorityRevision, revision: value.revision };
|
state.status = value.status; state.stamp = { epoch: value.epoch, authorityRevision: value.authorityRevision, revision: value.revision };
|
||||||
}
|
}
|
||||||
/** Runtime-only inherited IPC. No HTTP credential route or renderer method. */
|
/** Runtime-only: inherited desktop IPC or direct web owner. Never HTTP leases. */
|
||||||
export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
|
export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
|
||||||
const state = bridge();
|
const state = bridge();
|
||||||
|
if (webSubscriptionsEnabled()) return webSubscriptionRequest<T>("lh:chatgpt", op, fields);
|
||||||
if (!desktopIPCConnected()) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection."));
|
if (!desktopIPCConnected()) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection."));
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
const id = `${process.pid}:${++state.counter}`;
|
const id = `${process.pid}:${++state.counter}`;
|
||||||
@@ -74,7 +80,7 @@ export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Rec
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
export function chatGPTStatus(): ChatGPTStatus { return bridge().status; }
|
export function chatGPTStatus(): ChatGPTStatus { return bridge().status; }
|
||||||
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return desktopIPCConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
|
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return runtimeConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
|
||||||
export function chatGPTBackgroundAllowed(): boolean { const s = chatGPTStatus(); return chatGPTConnected() && s.allowBackgroundUse === true; }
|
export function chatGPTBackgroundAllowed(): boolean { const s = chatGPTStatus(); return chatGPTConnected() && s.allowBackgroundUse === true; }
|
||||||
export function onChatGPTChange(listener: () => void): () => void { bridge().listeners.add(listener); return () => { bridge().listeners.delete(listener); }; }
|
export function onChatGPTChange(listener: () => void): () => void { bridge().listeners.add(listener); return () => { bridge().listeners.delete(listener); }; }
|
||||||
export function chatGPTModels(): Model<"openai-responses">[] { return bridge().models; }
|
export function chatGPTModels(): Model<"openai-responses">[] { return bridge().models; }
|
||||||
@@ -86,7 +92,7 @@ export function chatGPTScope(selectedModelId: string): AdmissionScope {
|
|||||||
}
|
}
|
||||||
export function chatGPTScopeCurrent(scope: AdmissionScope): boolean {
|
export function chatGPTScopeCurrent(scope: AdmissionScope): boolean {
|
||||||
const s = bridge();
|
const s = bridge();
|
||||||
return desktopIPCConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
|
return runtimeConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
|
||||||
}
|
}
|
||||||
export async function chatGPTLease(scope: AdmissionScope, background: boolean): Promise<LeaseValue> {
|
export async function chatGPTLease(scope: AdmissionScope, background: boolean): Promise<LeaseValue> {
|
||||||
if (!chatGPTScopeCurrent(scope)) throw new Error("ChatGPT catalog authority changed");
|
if (!chatGPTScopeCurrent(scope)) throw new Error("ChatGPT catalog authority changed");
|
||||||
@@ -108,7 +114,7 @@ export async function chatGPTDenial(scope: AdmissionScope | undefined, controlle
|
|||||||
/** Preserve account order; unknown capabilities/windows are never fabricated. */
|
/** Preserve account order; unknown capabilities/windows are never fabricated. */
|
||||||
export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise<void> {
|
export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise<void> {
|
||||||
const state = bridge();
|
const state = bridge();
|
||||||
if (!desktopIPCConnected()) { clearCatalog(state); return; }
|
if (!runtimeConnected()) { clearCatalog(state); return; }
|
||||||
const sequence = ++state.catalogRequest;
|
const sequence = ++state.catalogRequest;
|
||||||
try {
|
try {
|
||||||
const value = await chatGPTRequest<ModelsValue>("models");
|
const value = await chatGPTRequest<ModelsValue>("models");
|
||||||
|
|||||||
@@ -41,18 +41,16 @@ export function unregisterJobAbort(jobId: string): void {
|
|||||||
* the liveness gate for the stale-job sweep: a "running" record with a
|
* the liveness gate for the stale-job sweep: a "running" record with a
|
||||||
* live registration belongs to a loop this process owns (hung or slow, it
|
* live registration belongs to a loop this process owns (hung or slow, it
|
||||||
* is the one truth — never re-spawn beside it); without one the record is
|
* is the one truth — never re-spawn beside it); without one the record is
|
||||||
* an orphan of a process death and recovers immediately. The span is
|
* an orphan of a process death and recovers immediately. The registration
|
||||||
* loop-start to delivery-completion: the runner's unregistering finally
|
* spans computation and cleanup only. Finished outcomes have independent,
|
||||||
* runs only after deliver() resolves, by which point finalizeJob has
|
* persisted delivery obligations; no worker waits for their consumption. */
|
||||||
* flipped the record — so absence of registration is unambiguous. */
|
|
||||||
export function isJobLive(jobId: string): boolean {
|
export function isJobLive(jobId: string): boolean {
|
||||||
return registry().has(jobId);
|
return registry().has(jobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Abort a running job's loop. Returns false when no controller is live —
|
/** Abort a running job's loop. Returns false when no controller is live —
|
||||||
* the job's loop is not executing (crashed, or in the delivering-result
|
* the job's loop is not executing. The endpoint separately refuses abort
|
||||||
* phase where the runner has already returned); the caller then owns the
|
* for a finished worker, even while its session delivery remains pending. */
|
||||||
* whole abort on the record side. */
|
|
||||||
export function fireJobAbort(jobId: string): boolean {
|
export function fireJobAbort(jobId: string): boolean {
|
||||||
const controller = registry().get(jobId);
|
const controller = registry().get(jobId);
|
||||||
if (!controller) return false;
|
if (!controller) return false;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import type { GraphStore } from "../../core/ports.ts";
|
|||||||
import { emit } from "../../core/events.ts";
|
import { emit } from "../../core/events.ts";
|
||||||
import { settleGraphLayout } from "../../core/layout-settle.ts";
|
import { settleGraphLayout } from "../../core/layout-settle.ts";
|
||||||
import { batchRuns, coverageOf, messageViewLines } from "../../core/coverage.ts";
|
import { batchRuns, coverageOf, messageViewLines } from "../../core/coverage.ts";
|
||||||
import type { LhMessage } from "../../core/native-messages.ts";
|
import { failedOutputCallIds, type LhMessage } from "../../core/native-messages.ts";
|
||||||
import { auditorGraphTools } from "../tools/graph.ts";
|
import { auditorGraphTools } from "../tools/graph.ts";
|
||||||
import { runSubmitAgent, healStringifiedArrays } from "./submit-agent.ts";
|
import { runSubmitAgent, healStringifiedArrays } from "./submit-agent.ts";
|
||||||
|
|
||||||
@@ -124,16 +124,20 @@ export function transcriptView(record: ChatRecord): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The extraction view under the teacher-prose ruling (owner, 2026-09-28):
|
/** The extraction view under the teacher-prose ruling (owner, 2026-09-28):
|
||||||
* ONLY the teacher agent's text blocks — no learner messages, no
|
* ONLY learner-facing teacher prose (text or send_to_user) — no learner
|
||||||
* thinking, no tool calls or results, no quiz blocks — each labeled with
|
* messages, thinking, other tool calls/results, or quiz blocks — labeled with
|
||||||
* its ACTUAL message id (owner, 2026-10-13): the view lines ARE
|
* its ACTUAL message id (owner, 2026-10-13): the view lines ARE
|
||||||
* core/coverage.ts messageViewLines ("TEACHER [id] text"), the exact
|
* core/coverage.ts messageViewLines ("TEACHER [id] text"), the exact
|
||||||
* lines the coverage fingerprints hash, so the extractor can anchor
|
* lines the coverage fingerprints hash, so the extractor can anchor
|
||||||
* every proposition to the passage it came from (the session submit's
|
* every proposition to the passage it came from (the session submit's
|
||||||
* required messageId). The shared markdown engine stays reserved for the
|
* required messageId). The shared markdown engine stays reserved for the
|
||||||
* future user-facing export. */
|
* future user-facing export. */
|
||||||
function proseView(messages: ReadonlyArray<LhMessage>, title?: string): string {
|
function proseView(
|
||||||
const lines = messages.flatMap((message) => messageViewLines(message, message.id));
|
messages: ReadonlyArray<LhMessage>,
|
||||||
|
title?: string,
|
||||||
|
failedOutputCalls: ReadonlySet<string> = failedOutputCallIds(messages),
|
||||||
|
): string {
|
||||||
|
const lines = messages.flatMap((message) => messageViewLines(message, message.id, failedOutputCalls));
|
||||||
const head = title && title.length > 0 ? `# ${title}\n\n` : "";
|
const head = title && title.length > 0 ? `# ${title}\n\n` : "";
|
||||||
return head + lines.join("\n\n");
|
return head + lines.join("\n\n");
|
||||||
}
|
}
|
||||||
@@ -1113,6 +1117,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
// stage runs (typed — the trigger skips, routes surface 503).
|
// stage runs (typed — the trigger skips, routes surface 503).
|
||||||
refuseIfStagesUnready();
|
refuseIfStagesUnready();
|
||||||
const covered = coverageOf(record.messages, graphStore.readCoverage(), chatId);
|
const covered = coverageOf(record.messages, graphStore.readCoverage(), chatId);
|
||||||
|
const failedOutputCalls = failedOutputCallIds(record.messages);
|
||||||
if (covered.uncovered.length === 0) {
|
if (covered.uncovered.length === 0) {
|
||||||
console.info(`[extraction ${chatId}] nothing uncovered — no-op`);
|
console.info(`[extraction ${chatId}] nothing uncovered — no-op`);
|
||||||
return { nodes: 0, chains: 0 };
|
return { nodes: 0, chains: 0 };
|
||||||
@@ -1148,14 +1153,14 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
// fingerprints' messageViewLines ("TEACHER [id] text"), one
|
// fingerprints' messageViewLines ("TEACHER [id] text"), one
|
||||||
// shared seam.
|
// shared seam.
|
||||||
const messages = batch.map((slot) => slot.message);
|
const messages = batch.map((slot) => slot.message);
|
||||||
const view = proseView(messages, record.title);
|
const view = proseView(messages, record.title, failedOutputCalls);
|
||||||
// The batch's teacher-prose ids — the ONLY anchors this
|
// The batch's teacher-prose ids — the ONLY anchors this
|
||||||
// job's submit may name (validateSessionAnchors at the
|
// job's submit may name (validateSessionAnchors at the
|
||||||
// submit seam refuses anything else: ids from other
|
// submit seam refuses anything else: ids from other
|
||||||
// batches — even real ones of this chat — other chats,
|
// batches — even real ones of this chat — other chats,
|
||||||
// and non-prose messages).
|
// and non-prose messages).
|
||||||
const batchIds = new Set(
|
const batchIds = new Set(
|
||||||
messages.filter((m) => messageViewLines(m, m.id).length > 0).map((m) => m.id),
|
messages.filter((m) => messageViewLines(m, m.id, failedOutputCalls).length > 0).map((m) => m.id),
|
||||||
);
|
);
|
||||||
setPhase(jobKey, "queued", "chat", undefined, part);
|
setPhase(jobKey, "queued", "chat", undefined, part);
|
||||||
const findings = await withExtractSlot(jobKey, async () => {
|
const findings = await withExtractSlot(jobKey, async () => {
|
||||||
@@ -1188,7 +1193,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
const batchStats = mergeFindings(chatId, findings, auditResult, { chatStore, graphStore }, {
|
const batchStats = mergeFindings(chatId, findings, auditResult, { chatStore, graphStore }, {
|
||||||
foldedAt,
|
foldedAt,
|
||||||
coverage: batch
|
coverage: batch
|
||||||
.filter((slot) => messageViewLines(slot.message, slot.message.id).length > 0)
|
.filter((slot) => slot.tokens > 0)
|
||||||
.map((slot) => ({ chatId, fp: slot.fp, tokens: slot.tokens, foldedAt })),
|
.map((slot) => ({ chatId, fp: slot.fp, tokens: slot.tokens, foldedAt })),
|
||||||
});
|
});
|
||||||
// The layout ledger evolves with the merge (sync — the
|
// The layout ledger evolves with the merge (sync — the
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 { Type } from "typebox";
|
import { Type } from "typebox";
|
||||||
|
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";
|
||||||
import { providerEventObserver, type ProviderEventObserver } from "../provider-events.ts";
|
import { providerEventObserver, type ProviderEventObserver } from "../provider-events.ts";
|
||||||
import type { PdfResult, PdfToolInput } from "../../core/contracts.ts";
|
import type { PdfResult, PdfToolInput } from "../../core/contracts.ts";
|
||||||
@@ -166,7 +167,7 @@ export async function runPdfReaderAgent(
|
|||||||
): Promise<PdfResult> {
|
): Promise<PdfResult> {
|
||||||
const headless = opts.headless ?? getHeadless();
|
const headless = opts.headless ?? getHeadless();
|
||||||
const maxIterations = opts.maxIterations ?? MAX_ITERATIONS;
|
const maxIterations = opts.maxIterations ?? MAX_ITERATIONS;
|
||||||
const pdfPath = input.path;
|
const pdfPath = resolveSourcePath(input.path);
|
||||||
|
|
||||||
// Deterministic upfront failure: unreadable / unparseable
|
// Deterministic upfront failure: unreadable / unparseable
|
||||||
// for renders — the brief never starts on a broken source.
|
// for renders — the brief never starts on a broken source.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { SearchProvider } from "../../core/search.ts";
|
|||||||
import {
|
import {
|
||||||
accumulateJobUsage,
|
accumulateJobUsage,
|
||||||
buildSubagentSteer,
|
buildSubagentSteer,
|
||||||
|
isSubagentFailure,
|
||||||
piUsageToStep,
|
piUsageToStep,
|
||||||
type DemoResult,
|
type DemoResult,
|
||||||
type DemoToolInput,
|
type DemoToolInput,
|
||||||
@@ -40,13 +41,13 @@ import { runVisualMaker } from "./visual-maker.ts";
|
|||||||
* (the job tools' execute fires this via the spawn seam, fire-and-forget),
|
* (the job tools' execute fires this via the spawn seam, fire-and-forget),
|
||||||
* updating the job's activity line on every iteration. On completion the
|
* updating the job's activity line on every iteration. On completion the
|
||||||
* runner builds the STEER MESSAGE (buildSubagentSteer) and hands it to
|
* runner builds the STEER MESSAGE (buildSubagentSteer) and hands it to
|
||||||
* `deliver`; the wake pipeline fires the server-side teacher turn and
|
* `deliver` without waiting. The worker's final outcome and pending
|
||||||
* flips the job to done/error when the lesson has actually resumed.
|
* delivery are saved together; the session owns delivery from then on.
|
||||||
*
|
*
|
||||||
* On failure the result is the failure shape (empty html/svg + "FAILED: …"
|
* On failure the result is the failure shape (empty html/svg + "FAILED: …"
|
||||||
* notes; research: failure brief) — and it STILL steers, because the
|
* notes; research: failure brief) — and it STILL steers, because the
|
||||||
* teacher must learn the outcome. If delivery itself fails, the job is
|
* teacher must learn the outcome. Failed delivery starts stay quietly
|
||||||
* marked error (the learner's activity card shows it).
|
* pending and never rewrite the worker's computation outcome.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,9 +68,9 @@ export interface JobRunnerDeps {
|
|||||||
chatStore: ChatStore;
|
chatStore: ChatStore;
|
||||||
jobStore: JobStore;
|
jobStore: JobStore;
|
||||||
/**
|
/**
|
||||||
* Hand a finished job's steer to the wake pipeline — resolves when the
|
* Hand a finished job's steer to the session delivery registry. The
|
||||||
* steer is delivered (the wake turn completed and the job status
|
* promise tracks durable marker acceptance, but the worker never waits
|
||||||
* flipped); rejects when delivery fails unrecoverably.
|
* on it. Its persisted pending obligation survives process restart.
|
||||||
*/
|
*/
|
||||||
deliver: (steer: SteerMessage) => Promise<void>;
|
deliver: (steer: SteerMessage) => Promise<void>;
|
||||||
/** Settings-resolved engine per role (model slots wave, 2026-09-20):
|
/** Settings-resolved engine per role (model slots wave, 2026-09-20):
|
||||||
@@ -96,8 +97,8 @@ export interface JobRunnerDeps {
|
|||||||
export interface JobRunner {
|
export interface JobRunner {
|
||||||
/** Run one job's sub-agent loop to completion (background; never throws).
|
/** Run one job's sub-agent loop to completion (background; never throws).
|
||||||
* The tool input rides along from dispatch (the spawn fires mid-stream,
|
* The tool input rides along from dispatch (the spawn fires mid-stream,
|
||||||
* before the tool part is persisted — only crash-recovery re-spawns
|
* before the tool part is persisted). Recovery reads saved job input,
|
||||||
* resolve the input from the transcript). */
|
* with transcript fallback for legacy jobs. Delivery is not awaited. */
|
||||||
runJob(jobId: string, input?: JobToolInput): Promise<void>;
|
runJob(jobId: string, input?: JobToolInput): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,14 +271,15 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the job's tool input: dispatch passes it directly; only a
|
/** Dispatch input wins, then the job's saved arguments. Legacy jobs
|
||||||
* crash-recovery re-spawn (no input) falls back to scanning the
|
* without saved input fall back to the durable transcript, which may
|
||||||
* transcript (by then onFinish has long persisted the part). */
|
* lack a call if the dispatching turn died before settlement. */
|
||||||
function resolveInput(
|
function resolveInput(
|
||||||
job: JobRecord,
|
job: JobRecord,
|
||||||
input: JobToolInput | undefined,
|
input: JobToolInput | undefined,
|
||||||
): JobToolInput | undefined {
|
): JobToolInput | undefined {
|
||||||
if (input) return input;
|
if (input) return input;
|
||||||
|
if (job.input !== undefined) return job.input as JobToolInput;
|
||||||
const chat = chatStore.getChat(job.chatId);
|
const chat = chatStore.getChat(job.chatId);
|
||||||
if (!chat) return undefined;
|
if (!chat) return undefined;
|
||||||
// Wave D: the record is native history — the scan reads the native
|
// Wave D: the record is native history — the scan reads the native
|
||||||
@@ -295,27 +297,28 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tail of every job: store the result, build the steer, hand it to
|
* Save the computation outcome and session delivery obligation together.
|
||||||
* the wake pipeline. The pipeline flips the status when the lesson has
|
* Worker liveness ends after cleanup, not after a lesson consumes the result.
|
||||||
* actually resumed — until then the job reads "running" so the client's
|
|
||||||
* activity card keeps polling and re-syncs once, at the end.
|
|
||||||
*/
|
*/
|
||||||
async function finishJob(jobId: string, result: ResearchResult | DemoResult | VisualResult | PdfResult): Promise<void> {
|
async function finishJob(jobId: string, result: ResearchResult | DemoResult | VisualResult | PdfResult): Promise<void> {
|
||||||
const job = jobStore.getJob(jobId);
|
const job = jobStore.getJob(jobId);
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
// Aborted mid-run or mid-delivery (abortJob flips the record before
|
// Aborted mid-run (abortJob flips the record before
|
||||||
// the loop unwinds): no result write, no steer — the abort path owns
|
// the loop unwinds): no result write, no steer — the abort path owns
|
||||||
// the record and queues the abort steer itself.
|
// the record and queues the abort steer itself.
|
||||||
if (job.status !== "running") return;
|
if (job.status !== "running") return;
|
||||||
jobStore.updateJob(jobId, { result, activity: "delivering result" });
|
const failed = isSubagentFailure(job.kind, result);
|
||||||
const steer = buildSubagentSteer(job, result);
|
jobStore.updateJob(jobId, {
|
||||||
try {
|
result,
|
||||||
await deliver(steer);
|
status: failed ? "error" : "done",
|
||||||
} catch (err) {
|
activity: failed ? "failed" : "finished",
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
delivery: { kind: "result", status: "pending" },
|
||||||
console.error(`[job ${jobId}] steer delivery failed:`, err);
|
});
|
||||||
jobStore.updateJob(jobId, { status: "error", error: reason, activity: "delivery failed" });
|
// The saved result now belongs to the session. Do not keep the worker,
|
||||||
}
|
// abort registration, or temporary resources alive behind a question.
|
||||||
|
void deliver(buildSubagentSteer(job, result)).catch((err: unknown) => {
|
||||||
|
console.error(`[job ${jobId}] pending delivery:`, err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Chat-record + input resolution shared by all three runners. */
|
/** Chat-record + input resolution shared by all three runners. */
|
||||||
@@ -343,7 +346,12 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
|||||||
const { job } = ctx;
|
const { job } = ctx;
|
||||||
|
|
||||||
const input = (passedInput ?? resolveInput(job, passedInput)) as ResearchToolInput | undefined;
|
const input = (passedInput ?? resolveInput(job, passedInput)) as ResearchToolInput | undefined;
|
||||||
const question = typeof input?.question === "string" && input.question.trim() ? input.question : "(no question)";
|
if (!(typeof input?.question === "string" && input.question.trim())) {
|
||||||
|
jobStore.updateJob(jobId, { error: "research question unavailable" });
|
||||||
|
await finishJob(jobId, { brief: "Research failed: the research question is unavailable.", sources: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const question = input.question;
|
||||||
const context = typeof input?.context === "string" && input.context.trim() ? input.context : undefined;
|
const context = typeof input?.context === "string" && input.context.trim() ? input.context : undefined;
|
||||||
|
|
||||||
const prompt = [
|
const prompt = [
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Api, Model, ModelAuth } from "@earendil-works/pi-ai";
|
import type { Api, Model, ModelAuth } from "@earendil-works/pi-ai";
|
||||||
|
import { onWebSubscriptionPacket, webSubscriptionRequest, webSubscriptionsEnabled } from "./subscription-runtime.ts";
|
||||||
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionScope, type NativeSubscriptionSnapshot, type NativeSubscriptionStatus } from "../core/contracts/subscriptions.ts";
|
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionScope, type NativeSubscriptionSnapshot, type NativeSubscriptionStatus } from "../core/contracts/subscriptions.ts";
|
||||||
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
|
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ export function nativeSubscriptionId(logicalId: string): NativeSubscriptionProvi
|
|||||||
function desktopConnected(): boolean {
|
function desktopConnected(): boolean {
|
||||||
return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true;
|
return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true;
|
||||||
}
|
}
|
||||||
|
function runtimeConnected(): boolean { return desktopConnected() || webSubscriptionsEnabled(); }
|
||||||
function notify(state: State) { for (const listener of state.listeners) { try { listener(); } catch { /* observer isolation */ } } }
|
function notify(state: State) { for (const listener of state.listeners) { try { listener(); } catch { /* observer isolation */ } } }
|
||||||
function invalidate(state: State, providerId?: NativeSubscriptionProviderId) {
|
function invalidate(state: State, providerId?: NativeSubscriptionProviderId) {
|
||||||
for (const [controller, owner] of state.controllers) if (!providerId || owner.providerId === providerId) controller.abort("all");
|
for (const [controller, owner] of state.controllers) if (!providerId || owner.providerId === providerId) controller.abort("all");
|
||||||
@@ -60,7 +62,11 @@ function bridge(): State {
|
|||||||
if (message.ok) pending.resolve(message.value);
|
if (message.ok) pending.resolve(message.value);
|
||||||
else pending.reject(new Error(message.error ?? "Subscription connection failed."));
|
else pending.reject(new Error(message.error ?? "Subscription connection failed."));
|
||||||
});
|
});
|
||||||
|
onWebSubscriptionPacket("lh:subscriptions", (packet) => {
|
||||||
|
if (webSubscriptionsEnabled() && packet.snapshot) accept(state, packet.snapshot as NativeSubscriptionSnapshot);
|
||||||
|
});
|
||||||
process.on("disconnect", () => {
|
process.on("disconnect", () => {
|
||||||
|
if (webSubscriptionsEnabled()) return;
|
||||||
invalidate(state);
|
invalidate(state);
|
||||||
for (const pending of state.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error("The desktop connection closed.")); }
|
for (const pending of state.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error("The desktop connection closed.")); }
|
||||||
state.pending.clear(); notify(state);
|
state.pending.clear(); notify(state);
|
||||||
@@ -70,6 +76,7 @@ function bridge(): State {
|
|||||||
}
|
}
|
||||||
export function subscriptionRequest<T>(op: "snapshot" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
|
export function subscriptionRequest<T>(op: "snapshot" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
|
||||||
const state = bridge();
|
const state = bridge();
|
||||||
|
if (webSubscriptionsEnabled()) return webSubscriptionRequest<T>("lh:subscriptions", op, fields);
|
||||||
if (!desktopConnected()) return Promise.reject(new Error("Subscription sign-in is available only in the desktop app with a private server connection."));
|
if (!desktopConnected()) return Promise.reject(new Error("Subscription sign-in is available only in the desktop app with a private server connection."));
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
const id = `${process.pid}:${++state.counter}`;
|
const id = `${process.pid}:${++state.counter}`;
|
||||||
@@ -82,17 +89,17 @@ export function subscriptionRequest<T>(op: "snapshot" | "lease", fields: Record<
|
|||||||
}
|
}
|
||||||
export async function refreshSubscriptionModels(): Promise<void> {
|
export async function refreshSubscriptionModels(): Promise<void> {
|
||||||
const state = bridge();
|
const state = bridge();
|
||||||
if (!desktopConnected()) { invalidate(state); return; }
|
if (!runtimeConnected()) { invalidate(state); return; }
|
||||||
try { accept(state, await subscriptionRequest<NativeSubscriptionSnapshot>("snapshot")); }
|
try { accept(state, await subscriptionRequest<NativeSubscriptionSnapshot>("snapshot")); }
|
||||||
catch { invalidate(state); notify(state); }
|
catch { invalidate(state); notify(state); }
|
||||||
}
|
}
|
||||||
export function nativeSubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus | undefined { return bridge().statuses.get(providerId); }
|
export function nativeSubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus | undefined { return bridge().statuses.get(providerId); }
|
||||||
export function nativeSubscriptionConnected(providerId: NativeSubscriptionProviderId): boolean {
|
export function nativeSubscriptionConnected(providerId: NativeSubscriptionProviderId): boolean {
|
||||||
const status = nativeSubscriptionStatus(providerId);
|
const status = nativeSubscriptionStatus(providerId);
|
||||||
return desktopConnected() && status?.available === true && status.connected && !!status.connectionId;
|
return runtimeConnected() && status?.available === true && status.connected && !!status.connectionId;
|
||||||
}
|
}
|
||||||
export function nativeSubscriptionBackgroundAllowed(providerId: NativeSubscriptionProviderId): boolean { return nativeSubscriptionConnected(providerId) && nativeSubscriptionStatus(providerId)?.allowBackgroundUse === true; }
|
export function nativeSubscriptionBackgroundAllowed(providerId: NativeSubscriptionProviderId): boolean { return nativeSubscriptionConnected(providerId) && nativeSubscriptionStatus(providerId)?.allowBackgroundUse === true; }
|
||||||
export function nativeSubscriptionModels(): Model<Api>[] { return desktopConnected() ? [...bridge().models.values()].flat() : []; }
|
export function nativeSubscriptionModels(): Model<Api>[] { return runtimeConnected() ? [...bridge().models.values()].flat() : []; }
|
||||||
export function nativeSubscriptionModel(logicalId: string, modelId: string): Model<Api> | undefined { return nativeSubscriptionModels().find((m) => m.provider === logicalId && m.id === modelId); }
|
export function nativeSubscriptionModel(logicalId: string, modelId: string): Model<Api> | undefined { return nativeSubscriptionModels().find((m) => m.provider === logicalId && m.id === modelId); }
|
||||||
export function onNativeSubscriptionChange(listener: () => void): () => void { const state = bridge(); state.listeners.add(listener); return () => { state.listeners.delete(listener); }; }
|
export function onNativeSubscriptionChange(listener: () => void): () => void { const state = bridge(); state.listeners.add(listener); return () => { state.listeners.delete(listener); }; }
|
||||||
export function subscriptionScope(providerId: NativeSubscriptionProviderId, selectedModelId: string): NativeSubscriptionScope {
|
export function subscriptionScope(providerId: NativeSubscriptionProviderId, selectedModelId: string): NativeSubscriptionScope {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/** Server-injected private web transport. The agent layer never imports a
|
||||||
|
* filesystem owner or server composition root. Desktop IPC remains separate. */
|
||||||
|
export type SubscriptionRuntimeChannel = "lh:chatgpt" | "lh:subscriptions";
|
||||||
|
export type SubscriptionRuntimePacket = Record<string, unknown>;
|
||||||
|
interface Runtime {
|
||||||
|
request?: <T>(channel: SubscriptionRuntimeChannel, op: string, fields: Record<string, unknown>) => Promise<T>;
|
||||||
|
listeners: Map<SubscriptionRuntimeChannel, Set<(packet: SubscriptionRuntimePacket) => void>>;
|
||||||
|
}
|
||||||
|
const KEY = Symbol.for("lh.webSubscriptionRuntime");
|
||||||
|
function runtime(): Runtime {
|
||||||
|
const global = globalThis as typeof globalThis & { [KEY]?: Runtime };
|
||||||
|
return global[KEY] ??= { listeners: new Map() };
|
||||||
|
}
|
||||||
|
export function configureWebSubscriptionRuntime(request: NonNullable<Runtime["request"]>): void { runtime().request = request; }
|
||||||
|
export function webSubscriptionsEnabled(): boolean {
|
||||||
|
return process.env.LH_DESKTOP_SERVER !== "1" && !process.env.LH_DESKTOP_API_SECRET && !!runtime().request;
|
||||||
|
}
|
||||||
|
export function onWebSubscriptionPacket(channel: SubscriptionRuntimeChannel, listener: (packet: SubscriptionRuntimePacket) => void): void {
|
||||||
|
const state = runtime(); const listeners = state.listeners.get(channel) ?? new Set();
|
||||||
|
listeners.add(listener); state.listeners.set(channel, listeners);
|
||||||
|
}
|
||||||
|
export function publishWebSubscriptionPacket(channel: SubscriptionRuntimeChannel, packet: SubscriptionRuntimePacket): void {
|
||||||
|
for (const listener of runtime().listeners.get(channel) ?? []) {
|
||||||
|
try { listener(packet); } catch { /* observer isolation */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function webSubscriptionRequest<T>(channel: SubscriptionRuntimeChannel, op: string, fields: Record<string, unknown>): Promise<T> {
|
||||||
|
if (!webSubscriptionsEnabled()) return Promise.reject(new Error("The web subscription owner is unavailable."));
|
||||||
|
return runtime().request!<T>(channel, op, fields);
|
||||||
|
}
|
||||||
+26
-12
@@ -15,10 +15,11 @@ import {
|
|||||||
type TeacherToolContext,
|
type TeacherToolContext,
|
||||||
} from "./tools.ts";
|
} from "./tools.ts";
|
||||||
import { practiceTools, type PracticeToolContext } from "./tools/practice.ts";
|
import { practiceTools, type PracticeToolContext } from "./tools/practice.ts";
|
||||||
import { healToolRecord } from "./tools/heal.ts";
|
import { healToolRecord, withArgumentHeal } from "./tools/heal.ts";
|
||||||
|
import { sendToUserTool } from "./tools/send-to-user.ts";
|
||||||
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
|
import { 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 } from "../core/native-messages.ts";
|
import { toLlmMessages, nativeInputCause, PRACTICE_PROBLEM_TOOL } from "../core/native-messages.ts";
|
||||||
import type { ChatGPTRequestCycle } from "./chatgpt-request-cycle.ts";
|
import type { ChatGPTRequestCycle } from "./chatgpt-request-cycle.ts";
|
||||||
import { usageWasAdmitted } from "./subscription-request-cycle.ts";
|
import { usageWasAdmitted } from "./subscription-request-cycle.ts";
|
||||||
|
|
||||||
@@ -145,22 +146,27 @@ export interface AgentBuildOptions {
|
|||||||
* belt-and-suspenders HALF for the mixed batch (a question + other calls
|
* belt-and-suspenders HALF for the mixed batch (a question + other calls
|
||||||
* in one message) that terminate-only semantics would let run on — the
|
* in one message) that terminate-only semantics would let run on — the
|
||||||
* learner cannot answer while a turn is in flight (per-chat 409), so a
|
* learner cannot answer while a turn is in flight (per-chat 409), so a
|
||||||
* cleanly-posed question MUST always end the turn. maxTurns is gone
|
* cleanly-posed question MUST always end the turn. Practice's problem pose
|
||||||
|
* has the same wait-for-the-learner contract, including mixed output/problem
|
||||||
|
* batches; failed poses must still retry. maxTurns is gone
|
||||||
* (owner ruling 2026-09-25: no maximum-agent-turns net on the teaching
|
* (owner ruling 2026-09-25: no maximum-agent-turns net on the teaching
|
||||||
* turn).
|
* turn).
|
||||||
*/
|
*/
|
||||||
function makeQuestionStopper() {
|
function makeQuestionStopper(kind: AgentKind) {
|
||||||
return (context: {
|
return (context: {
|
||||||
message: { content: Array<{ type: string; id?: string; name?: string }> };
|
message: { content: Array<{ type: string; id?: string; name?: string }> };
|
||||||
toolResults: Array<{ toolCallId: string; isError?: boolean }>;
|
toolResults: Array<{ toolCallId: string; isError?: boolean }>;
|
||||||
}): boolean => {
|
}): boolean => {
|
||||||
const called = new Set(
|
const called = new Set(
|
||||||
context.message.content
|
context.message.content
|
||||||
.filter((c) => c.type === "toolCall" && c.name !== undefined && (QUESTION_TOOLS as readonly string[]).includes(c.name!))
|
.filter((c) => c.type === "toolCall" && c.name !== undefined && (
|
||||||
|
(QUESTION_TOOLS as readonly string[]).includes(c.name) ||
|
||||||
|
(kind === "practice" && c.name === PRACTICE_PROBLEM_TOOL)
|
||||||
|
))
|
||||||
.map((c) => c.id!),
|
.map((c) => c.id!),
|
||||||
);
|
);
|
||||||
if (called.size === 0) return false;
|
if (called.size === 0) return false;
|
||||||
// Stop only when every question call of this turn resolved cleanly.
|
// Stop only when every question/problem pose resolved cleanly.
|
||||||
for (const result of context.toolResults) {
|
for (const result of context.toolResults) {
|
||||||
if (called.has(result.toolCallId) && result.isError) return false;
|
if (called.has(result.toolCallId) && result.isError) return false;
|
||||||
}
|
}
|
||||||
@@ -174,7 +180,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
|
|||||||
// conversation-spec resolution as teaching); the cast keeps AgentKind
|
// conversation-spec resolution as teaching); the cast keeps AgentKind
|
||||||
// ("practice" is not a ModelRole) honest about that.
|
// ("practice" is not a ModelRole) honest about that.
|
||||||
const engine = opts.engine ?? piEngineFor(kind as "prober" | "teacher");
|
const engine = opts.engine ?? piEngineFor(kind as "prober" | "teacher");
|
||||||
const stopper = makeQuestionStopper();
|
const stopper = makeQuestionStopper(kind);
|
||||||
const systemPrompt = opts.promptSwitches
|
const systemPrompt = opts.promptSwitches
|
||||||
? composeInstructions(kind, opts.promptSwitches)
|
? composeInstructions(kind, opts.promptSwitches)
|
||||||
: composedInstructions[kind]!;
|
: composedInstructions[kind]!;
|
||||||
@@ -190,7 +196,16 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
|
|||||||
systemPrompt,
|
systemPrompt,
|
||||||
model: engine.model,
|
model: engine.model,
|
||||||
thinkingLevel: engine.level,
|
thinkingLevel: engine.level,
|
||||||
tools,
|
// Conditional registry inclusion, like the graph-tool gates: when
|
||||||
|
// closed, neither the tool nor its usage instructions reach the model.
|
||||||
|
// Match Claude across providers (including OpenRouter), not the API
|
||||||
|
// transport. Explicit test registries remain caller-owned.
|
||||||
|
tools: [
|
||||||
|
...tools,
|
||||||
|
...(opts.tools === undefined && engine.model.id.toLowerCase().includes("claude")
|
||||||
|
? [withArgumentHeal(sendToUserTool)]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
messages: opts.messages ?? [],
|
messages: opts.messages ?? [],
|
||||||
},
|
},
|
||||||
streamFn: engine.streamFn,
|
streamFn: engine.streamFn,
|
||||||
@@ -312,10 +327,9 @@ export function bareQuestionTools(): Record<string, AgentTool<any>> {
|
|||||||
* from turn 1 of a kind:"practice" session — no prober→teacher split,
|
* from turn 1 of a kind:"practice" session — no prober→teacher split,
|
||||||
* no plan tools, no question tools. Its ENTIRE registry is
|
* no plan tools, no question tools. Its ENTIRE registry is
|
||||||
* problem + conclude (tools/practice.ts); the doctrine prompt
|
* problem + conclude (tools/practice.ts); the doctrine prompt
|
||||||
* (prompts/practice/) carries the method. The question-stop rule does
|
* (prompts/practice/) carries the method. A successful problem pose ends
|
||||||
* not apply (problem rides terminate: true natively —
|
* the run, natively for a problem-only batch and via the shared stop hook
|
||||||
* QUESTION_TOOLS does not include it), so baseAgentOptions's stopper is
|
* for a mixed batch (for example, send_to_user + problem).
|
||||||
* a no-op here; it stays wired for uniformity.
|
|
||||||
*/
|
*/
|
||||||
export function createPracticeAgent(ctx: PracticeToolContext, opts: AgentBuildOptions = {}) {
|
export function createPracticeAgent(ctx: PracticeToolContext, opts: AgentBuildOptions = {}) {
|
||||||
const tools = opts.tools ?? practiceTools(ctx);
|
const tools = opts.tools ?? practiceTools(ctx);
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ function dispatch(
|
|||||||
input: ResearchInput | DemoInput | VisualInput | PdfInput,
|
input: ResearchInput | DemoInput | VisualInput | PdfInput,
|
||||||
): string {
|
): string {
|
||||||
const existing = ctx.jobStore.findJobByToolCallId(ctx.chatId, toolCallId);
|
const existing = ctx.jobStore.findJobByToolCallId(ctx.chatId, toolCallId);
|
||||||
const job = existing ?? ctx.jobStore.createJob({ kind, chatId: ctx.chatId, toolCallId });
|
const job = existing ?? ctx.jobStore.createJob({ kind, chatId: ctx.chatId, toolCallId, input });
|
||||||
if (!existing) ctx.spawn(job, input);
|
if (!existing) ctx.spawn(job, input);
|
||||||
return `Dispatched (job ${job.id}). ${ack}`;
|
return `Dispatched (job ${job.id}). ${ack}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Type } from "typebox";
|
||||||
|
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||||
|
import { SEND_TO_USER_TOOL } from "../../core/native-messages.ts";
|
||||||
|
import { serverExecute } from "../tool-execute.ts";
|
||||||
|
|
||||||
|
const sendToUserInput = Type.Object({
|
||||||
|
message: Type.String({
|
||||||
|
minLength: 1,
|
||||||
|
pattern: "\\S",
|
||||||
|
description: "The complete content to display to the learner. Markdown and LaTeX are supported.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Claude's documented verbatim mid-turn output channel. Availability is
|
||||||
|
* model-gated at the shared conversation-agent construction boundary;
|
||||||
|
* the native call remains in history, and the view renders its message. */
|
||||||
|
export const sendToUserTool: AgentTool<typeof sendToUserInput> = {
|
||||||
|
name: SEND_TO_USER_TOOL,
|
||||||
|
label: SEND_TO_USER_TOOL,
|
||||||
|
description:
|
||||||
|
"## REQUIRED OUTPUT CHANNEL — ALWAYS USE `send_to_user`\n\n" +
|
||||||
|
"**ALL learner-facing messages MUST be sent using `send_to_user`.** This includes explanations, teaching, " +
|
||||||
|
"feedback, corrections, hints, introductions, progress messages, and final responses. " +
|
||||||
|
"There is no exception for short messages or the end of a turn.\n\n" +
|
||||||
|
"Ordinary response text may be converted into summarized thinking instead of reaching the learner. " +
|
||||||
|
"**Never rely on ordinary response text to communicate with the learner. Thinking is not delivery.**\n\n" +
|
||||||
|
"Put the complete message in `message`. Markdown and LaTeX are supported. " +
|
||||||
|
"Do not duplicate it in ordinary response text.\n\n" +
|
||||||
|
"Question content still belongs in the appropriate question tool; send any accompanying teaching or " +
|
||||||
|
"commentary through `send_to_user` before calling that tool.\n\n" +
|
||||||
|
"This tool does not pause or end your turn. Continue with other tools when needed. " +
|
||||||
|
"After delivering your final message, end the turn without additional ordinary response text.",
|
||||||
|
parameters: sendToUserInput,
|
||||||
|
executionMode: "sequential",
|
||||||
|
execute: serverExecute(SEND_TO_USER_TOOL, sendToUserInput, () => "Message received for display."),
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/** Read-only provenance for historical question cards. Never persist this
|
||||||
|
* projection: answer identities, ordering, grading, and learner text remain
|
||||||
|
* the recorded facts; only model-authored display text comes from the call. */
|
||||||
|
import { normalizeOptions, type QuestionAnswerData } from "./question-core.ts";
|
||||||
|
import { parseAskInput, parseExplainInput, parseQuizInput } from "./tool-parts.ts";
|
||||||
|
import { repairMathForDisplay } from "./math-delims.ts";
|
||||||
|
|
||||||
|
export function answerForPresentation(answer: QuestionAnswerData, input: unknown): QuestionAnswerData {
|
||||||
|
if (answer.dismissed) return answer;
|
||||||
|
const quiz = answer.kind === "quiz" ? parseQuizInput(input) : undefined;
|
||||||
|
const explain = answer.kind === "explain" ? parseExplainInput(input) : undefined;
|
||||||
|
const source = quiz ?? explain ?? (answer.kind === "ask" ? parseAskInput(input) : undefined);
|
||||||
|
if (!source) return answer;
|
||||||
|
const projected = { ...answer, questionText: source.question };
|
||||||
|
if (quiz) projected.explanation = quiz.explanation;
|
||||||
|
if (explain) projected.committed = explain.correctAnswer;
|
||||||
|
if (!answer.options?.length || !("options" in source) || !source.options?.length) return projected;
|
||||||
|
|
||||||
|
const original = normalizeOptions(source.options);
|
||||||
|
const saved = answer.options;
|
||||||
|
const bindings = new Map<number, number>(); // saved position -> original position
|
||||||
|
const used = new Set<number>();
|
||||||
|
const bindUnique = (candidates: number[][]) => {
|
||||||
|
for (let si = 0; si < saved.length; si++) {
|
||||||
|
if (bindings.has(si)) continue;
|
||||||
|
const possible = candidates[si]!.filter((oi) => !used.has(oi));
|
||||||
|
if (possible.length !== 1) continue;
|
||||||
|
const oi = possible[0]!;
|
||||||
|
// Two saved rows claiming the same source are ambiguous, even if
|
||||||
|
// each row independently has just one candidate.
|
||||||
|
if (candidates.some((other, sj) => sj !== si && !bindings.has(sj) && other.includes(oi))) continue;
|
||||||
|
bindings.set(si, oi);
|
||||||
|
used.add(oi);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Selected-answer values are stable identities when the record retained
|
||||||
|
// them. Do not normalize those values, recompute a shuffle, or regrade.
|
||||||
|
bindUnique(saved.map((option) => {
|
||||||
|
const values = answer.answers.filter((a) => a.index === option.index).map((a) => a.value);
|
||||||
|
return original.flatMap((o, oi) => values.includes(o.value) ? [oi] : []);
|
||||||
|
}));
|
||||||
|
const recoveredLabels = original.map((o) => repairMathForDisplay(o.label));
|
||||||
|
bindUnique(saved.map((option) => original.flatMap((o, oi) =>
|
||||||
|
o.label === option.label || recoveredLabels[oi] === option.label ? [oi] : [])));
|
||||||
|
// A bijection with one remaining pair has no ordering ambiguity. With
|
||||||
|
// multiple unmatched rows, keep their snapshots rather than guess.
|
||||||
|
if (saved.length === original.length && bindings.size === saved.length - 1) {
|
||||||
|
const si = saved.findIndex((_, i) => !bindings.has(i));
|
||||||
|
const oi = original.findIndex((_, i) => !used.has(i));
|
||||||
|
bindings.set(si, oi);
|
||||||
|
}
|
||||||
|
projected.options = saved.map((option, si) => {
|
||||||
|
const oi = bindings.get(si);
|
||||||
|
return oi === undefined ? option : { ...option, label: original[oi]!.label };
|
||||||
|
});
|
||||||
|
return projected;
|
||||||
|
}
|
||||||
@@ -272,9 +272,9 @@ export function newCompactionMarker(summary: string, details: CompactionDetails)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The ACTIVE compaction marker: the newest one in the record (chained
|
/** The ACTIVE compaction marker: the newest one with a retained-message
|
||||||
* compactions — the last one rules; older markers fall inside the
|
* id. Its summary supersedes older markers even when their stored
|
||||||
* dropped span and never render). */
|
* positions remain inside its retained tail. */
|
||||||
export function activeCompactionOf(messages: readonly LhMessage[]): (LhMarkerMessage & { details: CompactionDetails }) | null {
|
export function activeCompactionOf(messages: readonly LhMessage[]): (LhMarkerMessage & { details: CompactionDetails }) | null {
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
const m = messages[i]!;
|
const m = messages[i]!;
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/** Source-preserving boundaries for tool-authored Markdown. Recovery never
|
||||||
|
* mutates input, reserializes Markdown, or enters protected literal content. */
|
||||||
|
import katex from "katex";
|
||||||
|
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||||
|
import { mathFromMarkdown } from "mdast-util-math";
|
||||||
|
import { math } from "micromark-extension-math";
|
||||||
|
|
||||||
|
export type ContentSpan =
|
||||||
|
| { kind: "prose" | "code"; raw: string }
|
||||||
|
| { kind: "math"; raw: string; open: string; body: string; close: string; display: boolean };
|
||||||
|
const blankLine = /^\r?\n[ \t]*\r?\n/;
|
||||||
|
const escaped = (text: string, i: number) => {
|
||||||
|
let n = 0; while (i > 0 && text[--i] === "\\") n++;
|
||||||
|
return n % 2 === 1;
|
||||||
|
};
|
||||||
|
const runLength = (text: string, i: number, char: string) => { let n = 0; while (text[i + n] === char) n++; return n; };
|
||||||
|
type MarkdownNode = { type: string; position?: { start: { offset?: number }; end: { offset?: number } }; children?: MarkdownNode[] };
|
||||||
|
/** Use the rendering stack's actual CommonMark+math grammar for literal
|
||||||
|
* ranges, including nested quotes/lists and implicit container endings.
|
||||||
|
* Keep source offsets: never serialize the AST back into Markdown. */
|
||||||
|
function literalRanges(text: string): Array<{ start: number; end: number }> {
|
||||||
|
const pending: MarkdownNode[] = [fromMarkdown(text, { extensions: [math({ singleDollarTextMath: true })], mdastExtensions: [mathFromMarkdown()] })];
|
||||||
|
const ranges: Array<{ start: number; end: number }> = [];
|
||||||
|
while (pending.length) {
|
||||||
|
const node = pending.pop()!;
|
||||||
|
if (node.type === "code" || node.type === "inlineCode") {
|
||||||
|
const start = node.position?.start.offset, end = node.position?.end.offset;
|
||||||
|
if (start !== undefined && end !== undefined) ranges.push({ start, end });
|
||||||
|
} else if (node.children) pending.push(...node.children);
|
||||||
|
}
|
||||||
|
return ranges.sort((a, b) => a.start - b.start);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors the app's native dollar-run and legacy-delimiter conventions,
|
||||||
|
* with code and blank paragraphs as inline boundaries. */
|
||||||
|
export function readContentSpans(text: string): ContentSpan[] {
|
||||||
|
const spans: ContentSpan[] = [];
|
||||||
|
const literals = literalRanges(text);
|
||||||
|
let literal = 0, i = 0, plain = 0;
|
||||||
|
const opaque = (end: number) => {
|
||||||
|
if (plain < i) spans.push({ kind: "prose", raw: text.slice(plain, i) });
|
||||||
|
spans.push({ kind: "code", raw: text.slice(i, end) }); i = plain = end;
|
||||||
|
};
|
||||||
|
while (i < text.length) {
|
||||||
|
while (literal < literals.length && literals[literal]!.end <= i) literal++;
|
||||||
|
const barrier = literals[literal]?.start ?? Infinity;
|
||||||
|
if (i >= barrier) { opaque(literals[literal]!.end); continue; }
|
||||||
|
let open = "", close = "", display = false, after = -1, at = -1;
|
||||||
|
if (text[i] === "\\" && !escaped(text, i) && ["(", "["].includes(text[i + 1] ?? "")) {
|
||||||
|
open = text.slice(i, i + 2); close = open === "\\(" ? "\\)" : "\\]"; display = open === "\\[";
|
||||||
|
for (let j = i + 2; j < text.length - 1; j++) {
|
||||||
|
if (j >= barrier || (!display && blankLine.test(text.slice(j)))) break;
|
||||||
|
if (text.startsWith(close, j) && !escaped(text, j)) { at = j; after = j + 2; break; }
|
||||||
|
}
|
||||||
|
} else if (text[i] === "$" && !escaped(text, i)) {
|
||||||
|
const size = runLength(text, i, "$"); open = "$".repeat(size);
|
||||||
|
const eol = text.indexOf("\n", i + size);
|
||||||
|
const before = text.slice(text.lastIndexOf("\n", i - 1) + 1, i);
|
||||||
|
display = size >= 2 && /^[ \t]{0,3}$/.test(before) && !text.slice(i + size, eol < 0 ? text.length : eol).includes("$");
|
||||||
|
if (display) {
|
||||||
|
let start = eol;
|
||||||
|
while (start >= 0 && start < text.length) {
|
||||||
|
start++;
|
||||||
|
const next = text.indexOf("\n", start);
|
||||||
|
const match = /^([ \t]{0,3})(\$+)[ \t]*\r?$/.exec(text.slice(start, next < 0 ? text.length : next));
|
||||||
|
if (match && match[2]!.length >= size) { at = start + match[1]!.length; close = match[2]!; after = at + close.length; break; }
|
||||||
|
start = next;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let j = i + size; j < text.length;) {
|
||||||
|
if (j >= barrier || blankLine.test(text.slice(j))) break;
|
||||||
|
if (text[j] === "$" && !escaped(text, j)) {
|
||||||
|
const n = runLength(text, j, "$");
|
||||||
|
if (n === size) { at = j; close = open; after = j + n; break; }
|
||||||
|
j += n;
|
||||||
|
} else j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (at >= 0) {
|
||||||
|
if (plain < i) spans.push({ kind: "prose", raw: text.slice(plain, i) });
|
||||||
|
spans.push({ kind: "math", raw: text.slice(i, after), open, body: text.slice(i + open.length, at), close, display });
|
||||||
|
i = plain = after;
|
||||||
|
} else i += open.length || 1;
|
||||||
|
}
|
||||||
|
if (plain < text.length) spans.push({ kind: "prose", raw: text.slice(plain) });
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapMathContent(text: string, transform: (body: string, display: boolean) => string): string {
|
||||||
|
return readContentSpans(text).map(span => span.kind === "math" ? span.open + transform(span.body, span.display) + span.close : span.raw).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownCommands = new Map<string, boolean>();
|
||||||
|
/** Probe only a command name, never arbitrary content. Missing-argument errors
|
||||||
|
* still identify a known command. No trust, macros, IO, or persistent TeX state. */
|
||||||
|
export function knownTexCommand(name: string): boolean {
|
||||||
|
if (!/^[A-Za-z]+$/.test(name)) return false;
|
||||||
|
const cached = knownCommands.get(name); if (cached !== undefined) return cached;
|
||||||
|
let known = true;
|
||||||
|
try { katex.renderToString("\\" + name, { throwOnError: true, trust: false, strict: false }); }
|
||||||
|
catch (error) { known = !String(error).includes("Undefined control sequence: \\" + name); }
|
||||||
|
knownCommands.set(name, known);
|
||||||
|
return known;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canParseTex(body: string, display: boolean): boolean {
|
||||||
|
try { katex.renderToString(body, { throwOnError: true, trust: false, strict: false, displayMode: display }); return true; }
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TexRange { start: number; end: number; kind: "row" | "encoded" | "comment"; factor?: number }
|
||||||
|
export function numericPercentEscape(inner: string, percent: number): boolean {
|
||||||
|
let start = percent; while (start > 0 && inner[start - 1] === "\\") start--;
|
||||||
|
if (percent - start < 2 || (percent - start) % 2 !== 0 || !/\d[ \t]*$/.test(inner.slice(0, start))) return false;
|
||||||
|
const tail = inner.slice(percent + 1);
|
||||||
|
// A final source-formatting LF before the closing fence is not a next
|
||||||
|
// equation row. A meaningful following line or comment text remains literal.
|
||||||
|
if (/^[\s.)\]}]*$/.test(tail)) return true;
|
||||||
|
return !/[\r\n]/.test(tail) && !/^[ \t]*[A-Za-z]/.test(tail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TeX boundaries shared by document decoding and command recovery. Native
|
||||||
|
* rows are literal; consistently extra-escaped environment pairs supply an
|
||||||
|
* explicit escaping factor for both commands AND row separators. */
|
||||||
|
export function texRanges(inner: string): TexRange[] {
|
||||||
|
const ranges: TexRange[] = [];
|
||||||
|
const stack: Array<{ name: string; start: number; factor: number }> = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < inner.length) {
|
||||||
|
if (inner[i] === "%" && !escaped(inner, i)) {
|
||||||
|
let start = i; while (start > 0 && inner[start - 1] === "\\") start--;
|
||||||
|
const factor = stack.at(-1)?.factor ?? ranges.filter(r => r.kind === "encoded" && i >= r.start && i < r.end).at(-1)?.factor;
|
||||||
|
const encodedLiteral = factor && factor > 1 && (i - start) % factor === 0 && ((i - start) / factor) % 2 === 1;
|
||||||
|
const native = stack.some(frame => frame.factor === 1) || ranges.some(r => r.kind === "row" && i >= r.start && i < r.end);
|
||||||
|
if (encodedLiteral || (!native && numericPercentEscape(inner, i))) { i++; continue; }
|
||||||
|
const newline = inner.indexOf("\n", i);
|
||||||
|
const end = newline < 0 ? inner.length : newline + 1;
|
||||||
|
ranges.push({ start: i, end, kind: "comment" }); i = end; continue;
|
||||||
|
}
|
||||||
|
if (inner[i] !== "\\") { i++; continue; }
|
||||||
|
let end = i + 1; while (inner[end] === "\\") end++;
|
||||||
|
const count = end - i;
|
||||||
|
const marker = /^(begin|end)\s*\{([^{}]+)\}/.exec(inner.slice(end));
|
||||||
|
if (marker) {
|
||||||
|
const factor = count % 2 === 1 ? 1 : count;
|
||||||
|
if (marker[1] === "begin") stack.push({ name: marker[2]!, start: i, factor });
|
||||||
|
else if (stack.at(-1)?.name === marker[2] && stack.at(-1)?.factor === factor) {
|
||||||
|
const open = stack.pop()!;
|
||||||
|
ranges.push({ start: open.start, end: end + marker[0].length, kind: factor === 1 ? "row" : "encoded", factor });
|
||||||
|
}
|
||||||
|
i = end + marker[0].length; continue;
|
||||||
|
}
|
||||||
|
// KaTeX's substack macro expands its braced argument into subarray:
|
||||||
|
// these are row separators even without literal begin/end markers.
|
||||||
|
const substack = /^substack\s*\{/.exec(inner.slice(end));
|
||||||
|
if (substack) {
|
||||||
|
let depth = 1, j = end + substack[0].length;
|
||||||
|
for (; j < inner.length && depth > 0; j++) {
|
||||||
|
if (inner[j] === "%" && !escaped(inner, j)) { const lf = inner.indexOf("\n", j); j = lf < 0 ? inner.length : lf; continue; }
|
||||||
|
if (!escaped(inner, j)) { if (inner[j] === "{") depth++; else if (inner[j] === "}") depth--; }
|
||||||
|
}
|
||||||
|
if (depth === 0) ranges.push({ start: i, end: j, kind: count % 2 === 1 ? "row" : "encoded", factor: count % 2 === 1 ? 1 : count });
|
||||||
|
}
|
||||||
|
i = end;
|
||||||
|
}
|
||||||
|
return ranges.sort((a, b) => a.start - b.start);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function outsideTexComments(body: string, transform: (part: string) => string): string {
|
||||||
|
let from = 0; const out: string[] = [];
|
||||||
|
for (const range of texRanges(body).filter(r => r.kind === "comment")) {
|
||||||
|
out.push(transform(body.slice(from, range.start)), body.slice(range.start, range.end)); from = range.end;
|
||||||
|
}
|
||||||
|
out.push(transform(body.slice(from))); return out.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Document escapes are not arbitrary string decoding. Complete known TeX
|
||||||
|
* words win over the prefix `\\n`; code is never inspected or rewritten. */
|
||||||
|
export function recoverDocumentLayout(text: string): string {
|
||||||
|
const spans = readContentSpans(text);
|
||||||
|
const candidates = spans.filter(span => span.kind !== "code").map(span => span.raw).join("\u0000");
|
||||||
|
const declared = new Set([
|
||||||
|
...candidates.matchAll(/\\(?:newcommand|renewcommand|providecommand)\*?\s*(?:\{\s*)?\\(n[A-Za-z]+)/g),
|
||||||
|
...candidates.matchAll(/\\(?:def|gdef|edef)\s*\\(n[A-Za-z]+)/g),
|
||||||
|
].map(m => m[1]!));
|
||||||
|
const knownN = (name: string) => declared.has(name) || knownTexCommand(name);
|
||||||
|
const tokens = [...candidates.matchAll(/(\\+)(n[A-Za-z]*)/g)];
|
||||||
|
const evidence = tokens.filter(m => m[1]!.length % 2 === 1 && !knownN(m[2]!));
|
||||||
|
const structural = evidence.length >= 2 || candidates.includes("\\n\\n") || (evidence.length > 0 &&
|
||||||
|
[...candidates.matchAll(/(\\+)([A-Za-z]+)/g)].some(m => m[1]!.length % 2 === 0 && knownTexCommand(m[2]!)));
|
||||||
|
if (!structural) return text;
|
||||||
|
const decode = (part: string) => part.replace(/(\\+)(n[A-Za-z]*)/g, (whole: string, slashes: string, name: string, offset: number) => {
|
||||||
|
if (slashes.length % 2 === 0 || knownN(name)) return whole;
|
||||||
|
const word = part.slice(0, offset).split(/\s/).at(-1) ?? "";
|
||||||
|
if (/^(?:[A-Za-z]:[\\/]|https?:\/\/|\/)/.test(word + whole)) return whole;
|
||||||
|
return slashes.slice(0, -1) + "\n" + name.slice(1);
|
||||||
|
});
|
||||||
|
const projected = spans.map(span => ({ span, body: span.kind === "math" ? outsideTexComments(span.body, decode) : span.kind === "code" ? span.raw : decode(span.raw) }));
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < projected.length; i++) {
|
||||||
|
const { span, body } = projected[i]!;
|
||||||
|
if (span.kind !== "math") { out += body; continue; }
|
||||||
|
// Decoding document layout can expose a flow opener whose close was
|
||||||
|
// written on the formula line. Frame BOTH ends; otherwise Markdown
|
||||||
|
// consumes the closing dollars as TeX and introduces a rendering error.
|
||||||
|
if (/^\${2,}$/.test(span.open) && body !== span.body && body.includes("\n")) {
|
||||||
|
if (!/^[ \t]{0,3}$/.test(out.slice(out.lastIndexOf("\n") + 1))) out += "\n\n";
|
||||||
|
out += span.open + (body.startsWith("\n") ? "" : "\n") + body + (body.endsWith("\n") ? "" : "\n") + span.close;
|
||||||
|
const next = projected[i + 1];
|
||||||
|
if (next && !/^[\r\n]/.test(next.span.kind === "math" ? next.span.open : next.body)) out += "\n\n";
|
||||||
|
} else out += span.open + body + span.close;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
+28
-32
@@ -21,6 +21,12 @@ export interface JobRecord {
|
|||||||
chatId: string;
|
chatId: string;
|
||||||
/** The tool part this job fulfills. */
|
/** The tool part this job fulfills. */
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
|
/** Dispatch-time arguments, saved before spawn. Older jobs resolve from history. */
|
||||||
|
input?: unknown;
|
||||||
|
/** Session-owned delivery, independent of the worker's terminal outcome.
|
||||||
|
* Absent on legacy records: old terminal jobs must never be replayed. */
|
||||||
|
delivery?: { kind: "result" | "abort"; status: "pending" | "delivered" };
|
||||||
|
/** Worker computation only; a pending delivery does not keep it running. */
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
/** Latest human-readable activity line, e.g. `web_search: "sigma.js v3"`. */
|
/** Latest human-readable activity line, e.g. `web_search: "sigma.js v3"`. */
|
||||||
activity: string;
|
activity: string;
|
||||||
@@ -148,42 +154,32 @@ export function accumulateJobUsage(
|
|||||||
* sourced briefs) — tracking a live activity line in the job record.
|
* sourced briefs) — tracking a live activity line in the job record.
|
||||||
* ONE central client poll of GET /api/jobs?chatId=… (in Chat) drives every
|
* ONE central client poll of GET /api/jobs?chatId=… (in Chat) drives every
|
||||||
* job surface: the transcript dispatch cards (live activity by
|
* job surface: the transcript dispatch cards (live activity by
|
||||||
* toolCallId, quiet one-liner after delivery), the sub-agents sidebar
|
* toolCallId, quiet one-liner after worker completion), the sub-agents sidebar
|
||||||
* panel, and the single re-sync when a job settles.
|
* panel, and the single re-sync when a job settles.
|
||||||
*
|
*
|
||||||
* 3. On completion the runner builds a STEER MESSAGE (buildSubagentSteer)
|
* 3. Dispatch saves input on the job before spawn. On completion the
|
||||||
* and hands it to the wake pipeline (src/server/jobs.ts): a user-role
|
* runner saves result + terminal worker status + pending delivery in
|
||||||
* result-carrying native marker — the content is what the MODEL needs
|
* one job update, then queues buildSubagentSteer WITHOUT waiting for
|
||||||
* (the research brief, the builder's notes), plus a data-subagent-artifact
|
* delivery. The native custom marker's content is model-facing text;
|
||||||
* part with what the UI renders (the full result: brief + sources /
|
* details carry the full artifact payload for the UI. Worker resources
|
||||||
* SVG / demo HTML). Provider conversion drops data parts, so artifact
|
* are released even if a question keeps the result pending indefinitely.
|
||||||
* payloads never enter the teacher's context. metadata.subagentResult
|
* Only computing workers appear in Background agents or accept abort.
|
||||||
* identifies the job; the UI renders these as sub-agent result cards,
|
|
||||||
* NEVER as learner interjections.
|
|
||||||
*
|
*
|
||||||
* 4. The wake pipeline delivers the steer by firing a SERVER-SIDE teacher
|
* 4. Session delivery owns one registry entry per deterministic steer ID,
|
||||||
* turn through the same turn composition as a client request
|
* including while a wake is in flight or a 409/403 returns its cargo.
|
||||||
* (handleChatRequest) — write-ahead persist, turn liveness, extraction
|
* A live agent takes it via pi steering; registration restores saved
|
||||||
* trigger: one code path. A per-chat serialized queue: a steer waits
|
* pending jobs before the answer run's first poll. Otherwise the idle
|
||||||
* while a turn is in flight OR a question tool part is pending (an
|
* wake uses the normal turn composition and respects pending questions,
|
||||||
* unanswered client-side tool call breaks provider conversion — the
|
* dismissal, and background consent. A saved transcript marker completes
|
||||||
* steer fires right after the learner's continuation), then fires; a 409
|
* delivery; HTTP 200 or streamed visibility alone does not.
|
||||||
* (a client turn raced in) retries. Delivery is idempotent per jobId, so
|
|
||||||
* a crashed wake can be re-entered by the stale-job recovery sweep. The
|
|
||||||
* job's status flips to done/error only AFTER the wake turn completes —
|
|
||||||
* a polling client that sees the flip re-syncs once and gets the
|
|
||||||
* finished record.
|
|
||||||
*
|
*
|
||||||
* CONTINUATION COALESCING: the idle window above can starve — a
|
* Failed starts remain quietly pending until a learner run or restart
|
||||||
* quiz-chaining teacher ends every turn with a new pending check, so
|
* recovery, without new timers, visible delivery errors, or worker-status
|
||||||
* the gates never stay open long enough for a wake to fire. The fix:
|
* changes. Existing 409 backoff and consent deferral remain. Accepted
|
||||||
* when a client turn wins the turn registry, the handler CLAIMS every
|
* turns are never automatically replayed. Legacy running records with a
|
||||||
* queued, undelivered steer (jobs.ts claimSteers) and appends it after
|
* result become finished/pending; legacy terminal records with no
|
||||||
* the learner's answer in that very turn — conversion-safe, because the
|
* delivery metadata remain untouched. Session deletion discards pending
|
||||||
* answered quiz carries its output. Every answer flushes pending
|
* deliveries rather than treating them as running work.
|
||||||
* steers immediately; the wake pump remains for turns that end with no
|
|
||||||
* pending question. The pump re-checks steerDelivered right before
|
|
||||||
* firing, so a claim racing the pump converges to exactly one delivery.
|
|
||||||
*
|
*
|
||||||
* 5. No pending job parts ever exist, so the composer never gates on jobs
|
* 5. No pending job parts ever exist, so the composer never gates on jobs
|
||||||
* (the learner can interject mid-job; the steer queues behind their
|
* (the learner can interject mid-job; the steer queues behind their
|
||||||
|
|||||||
+25
-27
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Extraction coverage — the pure core. THE TEACHER'S PROSE IS THE UNIT
|
* Extraction coverage — the pure core. THE TEACHER'S PROSE IS THE UNIT
|
||||||
* (owner ruling, 2026-09-28): the extractor sees ONLY the teacher agent's
|
* (owner ruling, 2026-09-28): the extractor sees ONLY the teacher agent's
|
||||||
* text blocks — no learner messages, no thinking, no tool calls or
|
* prose — ordinary text blocks and explicit send_to_user messages. No
|
||||||
* results, no quiz blocks. Everything else is INVISIBLE to coverage: it
|
* learner messages, thinking, other tool calls/results, or quiz blocks.
|
||||||
* neither opens nor joins a run, so answering a quiz or editing a learner
|
* Everything else is INVISIBLE to coverage: it neither opens nor joins a run, so answering a quiz or editing a learner
|
||||||
* message triggers nothing. "Covered" is by construction exactly "what
|
* message triggers nothing. "Covered" is by construction exactly "what
|
||||||
* the extractor saw": the fingerprint (sha256 of a message's view lines)
|
* the extractor saw": the fingerprint (sha256 of a message's view lines)
|
||||||
* and the extractor's prompt share ONE selection — the teacher prose.
|
* and the extractor's prompt share ONE selection — the teacher prose.
|
||||||
@@ -22,33 +22,27 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
import type { CoverageEntry } from "./contracts/graph.ts";
|
import type { CoverageEntry } from "./contracts/graph.ts";
|
||||||
import type { LhMessage } from "./native-messages.ts";
|
import { failedOutputCallIds, userFacingTextOf, type LhMessage } from "./native-messages.ts";
|
||||||
|
|
||||||
export type { CoverageEntry };
|
export type { CoverageEntry };
|
||||||
|
|
||||||
/** The user-facing text of a native message (string or text blocks). */
|
|
||||||
function textOf(content: unknown): string[] {
|
|
||||||
if (typeof content === "string") return content.trim() ? [content] : [];
|
|
||||||
if (!Array.isArray(content)) return [];
|
|
||||||
return content
|
|
||||||
.filter((c): c is { type: "text"; text: string } => (c as { type?: string }).type === "text")
|
|
||||||
.map((c) => c.text)
|
|
||||||
.filter((t) => t.trim().length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The extraction-relevant lines of ONE native message — the shared seam
|
* The extraction-relevant lines of ONE native message — the shared seam
|
||||||
* between the extractor's prompt and messageFingerprint (coverage).
|
* between the extractor's prompt and messageFingerprint (coverage).
|
||||||
* TEACHER PROSE ONLY (owner, 2026-09-28): an assistant message yields one
|
* TEACHER PROSE ONLY (owner, 2026-09-28): an assistant message yields one
|
||||||
* line per text block; everything else (user messages, tool results,
|
* line per learner-facing prose block; everything else (user messages,
|
||||||
* markers) yields NOTHING and is invisible to coverage. `mid` is the
|
* tool results, markers) yields NOTHING and is invisible to coverage.
|
||||||
* message id — part of the hash input, so the same prose in two messages
|
* `mid` is the message id — part of the hash input, so the same prose in two messages
|
||||||
* is two slots (per-message coverage).
|
* is two slots (per-message coverage).
|
||||||
*/
|
*/
|
||||||
export function messageViewLines(message: LhMessage, mid: string): string[] {
|
export function messageViewLines(
|
||||||
|
message: LhMessage,
|
||||||
|
mid: string,
|
||||||
|
failedOutputCalls: ReadonlySet<string> = failedOutputCallIds([message]),
|
||||||
|
): string[] {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
if ((message as { role?: string }).role !== "assistant") return lines;
|
if ((message as { role?: string }).role !== "assistant") return lines;
|
||||||
for (const text of textOf((message as { content: unknown }).content)) {
|
for (const text of userFacingTextOf((message as { content: unknown }).content, failedOutputCalls)) {
|
||||||
lines.push(`TEACHER [${mid}] ${text.trim()}`);
|
lines.push(`TEACHER [${mid}] ${text.trim()}`);
|
||||||
}
|
}
|
||||||
return lines;
|
return lines;
|
||||||
@@ -62,9 +56,10 @@ export function messageViewLines(message: LhMessage, mid: string): string[] {
|
|||||||
*/
|
*/
|
||||||
export function teacherProseBlocks(messages: ReadonlyArray<LhMessage>): string[] {
|
export function teacherProseBlocks(messages: ReadonlyArray<LhMessage>): string[] {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
|
const failedOutputCalls = failedOutputCallIds(messages);
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if ((message as { role?: string }).role !== "assistant") continue;
|
if ((message as { role?: string }).role !== "assistant") continue;
|
||||||
for (const text of textOf((message as { content: unknown }).content)) out.push(text.trim());
|
for (const text of userFacingTextOf((message as { content: unknown }).content, failedOutputCalls)) out.push(text.trim());
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -79,9 +74,9 @@ export function viewTokens(lines: string[]): number {
|
|||||||
* teacher prose, with the message id as the tag). Changes exactly when
|
* teacher prose, with the message id as the tag). Changes exactly when
|
||||||
* the teacher prose of the message changes — nothing else can re-open it.
|
* the teacher prose of the message changes — nothing else can re-open it.
|
||||||
*/
|
*/
|
||||||
export function messageFingerprint(message: LhMessage): string {
|
export function messageFingerprint(message: LhMessage, failedOutputCalls?: ReadonlySet<string>): string {
|
||||||
const h = createHash("sha256");
|
const h = createHash("sha256");
|
||||||
h.update(JSON.stringify([(message as { role?: string }).role, messageViewLines(message, message.id)]));
|
h.update(JSON.stringify([(message as { role?: string }).role, messageViewLines(message, message.id, failedOutputCalls)]));
|
||||||
return h.digest("hex");
|
return h.digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +90,12 @@ export interface CoverageSlot {
|
|||||||
/** Fingerprint a whole transcript, in order (used by the trigger, the run,
|
/** Fingerprint a whole transcript, in order (used by the trigger, the run,
|
||||||
* and the coverage view — one derivation everywhere). */
|
* and the coverage view — one derivation everywhere). */
|
||||||
export function coverageSlots(messages: ReadonlyArray<LhMessage>): CoverageSlot[] {
|
export function coverageSlots(messages: ReadonlyArray<LhMessage>): CoverageSlot[] {
|
||||||
return messages.map((message) => ({ message, fp: messageFingerprint(message), tokens: viewTokens(messageViewLines(message, message.id)) }));
|
const failedOutputCalls = failedOutputCallIds(messages);
|
||||||
|
return messages.map((message) => ({
|
||||||
|
message,
|
||||||
|
fp: messageFingerprint(message, failedOutputCalls),
|
||||||
|
tokens: viewTokens(messageViewLines(message, message.id, failedOutputCalls)),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The covered-fingerprint set for one chat, from the ledger. */
|
/** The covered-fingerprint set for one chat, from the ledger. */
|
||||||
@@ -131,16 +131,14 @@ export function coverageOf(
|
|||||||
let uncoveredTokens = 0;
|
let uncoveredTokens = 0;
|
||||||
for (let i = 0; i < slots.length; i++) {
|
for (let i = 0; i < slots.length; i++) {
|
||||||
const slot = slots[i]!;
|
const slot = slots[i]!;
|
||||||
if (messageViewLines(slot.message, slot.message.id).length === 0) continue; // invisible
|
if (slot.tokens === 0) continue; // invisible (including failed output calls)
|
||||||
if (covered.has(slot.fp)) coveredTokens += slot.tokens;
|
if (covered.has(slot.fp)) coveredTokens += slot.tokens;
|
||||||
else {
|
else {
|
||||||
uncovered.push(i);
|
uncovered.push(i);
|
||||||
uncoveredTokens += slot.tokens;
|
uncoveredTokens += slot.tokens;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const visibleCount = slots.length - slots.filter(
|
const visibleCount = slots.filter((s) => s.tokens > 0).length;
|
||||||
(s) => messageViewLines(s.message, s.message.id).length === 0,
|
|
||||||
).length;
|
|
||||||
return {
|
return {
|
||||||
slots,
|
slots,
|
||||||
uncovered,
|
uncovered,
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export interface ServerEvents {
|
|||||||
"activity-changed": Record<string, never>;
|
"activity-changed": Record<string, never>;
|
||||||
/** The session list changed (create, delete, title). */
|
/** The session list changed (create, delete, title). */
|
||||||
"chats-changed": Record<string, never>;
|
"chats-changed": Record<string, never>;
|
||||||
|
/** Web subscription state changed. No account or credential data on the bus. */
|
||||||
|
"subscriptions-changed": Record<string, never>;
|
||||||
/** A chat's record changed WITHOUT a turn (the silent dismissal write).
|
/** A chat's record changed WITHOUT a turn (the silent dismissal write).
|
||||||
* Same notify-then-fetch contract as the turn events: open views of
|
* Same notify-then-fetch contract as the turn events: open views of
|
||||||
* the chat refetch and adopt the durable record. */
|
* the chat refetch and adopt the durable record. */
|
||||||
@@ -61,6 +63,7 @@ export const SERVER_EVENT_TYPES = [
|
|||||||
"jobs-changed",
|
"jobs-changed",
|
||||||
"activity-changed",
|
"activity-changed",
|
||||||
"chats-changed",
|
"chats-changed",
|
||||||
|
"subscriptions-changed",
|
||||||
"record-changed",
|
"record-changed",
|
||||||
"compaction-changed",
|
"compaction-changed",
|
||||||
] as const satisfies readonly ServerEventType[];
|
] as const satisfies readonly ServerEventType[];
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export * from "./input-events.ts";
|
|||||||
// the deleted server projection to the client side. Pure, browser-safe.
|
// the deleted server projection to the client side. Pure, browser-safe.
|
||||||
export * from "./view.ts";
|
export * from "./view.ts";
|
||||||
export * from "./tool-parts.ts";
|
export * from "./tool-parts.ts";
|
||||||
|
export * from "./answer-presentation.ts";
|
||||||
export * from "./prices.ts";
|
export * from "./prices.ts";
|
||||||
export * from "./context-usage.ts";
|
export * from "./context-usage.ts";
|
||||||
export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts";
|
export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts";
|
||||||
|
|||||||
+107
-558
@@ -1,572 +1,121 @@
|
|||||||
/**
|
/** Shared Markdown/TeX boundaries for legacy delimiter compatibility and
|
||||||
* Legacy math delimiter preprocessing — the \(...\)/\[...\] reader.
|
* display-only tool-content recovery. Source arguments are never rewritten. */
|
||||||
*
|
import {
|
||||||
* Current authoring uses `$...$` inline and `$$...$$` on separate lines for
|
canParseTex, knownTexCommand, mapMathContent, numericPercentEscape, outsideTexComments,
|
||||||
* display (owner, 2026-10-12). This pure transform keeps correctly formed
|
readContentSpans, recoverDocumentLayout, texRanges,
|
||||||
* old backslash-delimited content readable; dollar math passes through to
|
} from "./content-reader.ts";
|
||||||
* Streamdown's native parser. Escape restoration below recognizes both
|
|
||||||
* syntaxes while retaining the existing recovery rules.
|
|
||||||
*
|
|
||||||
* Rules:
|
|
||||||
* - `\(...\)` → `$$...$$` (two-dollar inline, still accepted by micromark),
|
|
||||||
* `\[...\]` → `$$\n...\n$$` (block form: flow math).
|
|
||||||
* - An escaped delimiter (`\\(` = literal backslash + paren) is left alone.
|
|
||||||
* - Nothing inside fenced code blocks or inline code spans is touched.
|
|
||||||
* - Unbalanced delimiters are left untouched: Streamdown streams chunks, and
|
|
||||||
* an incomplete `\[` completes on a later chunk (the whole text is
|
|
||||||
* re-transformed each render).
|
|
||||||
*
|
|
||||||
* Pure string → string. No markdown parsing — a single linear scan with a
|
|
||||||
* code-state machine, cheap enough to run in the render path of a memo'd
|
|
||||||
* component.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const INLINE_OPEN = "\\(";
|
const escaped = (text: string, i: number) => {
|
||||||
const INLINE_CLOSE = "\\)";
|
let n = 0; while (i > 0 && text[--i] === "\\") n++;
|
||||||
const DISPLAY_OPEN = "\\[";
|
return n % 2 === 1;
|
||||||
const DISPLAY_CLOSE = "\\]";
|
|
||||||
|
|
||||||
const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is the backslash at `i` itself escaped (i.e. preceded by an odd number of
|
|
||||||
* backslashes)? If so, the `\(`/`\[` starting there is a literal backslash
|
|
||||||
* followed by a bracket — not a delimiter.
|
|
||||||
*/
|
|
||||||
function isEscaped(text: string, i: number): boolean {
|
|
||||||
let count = 0;
|
|
||||||
for (let j = i - 1; j >= 0 && text[j] === "\\"; j--) count += 1;
|
|
||||||
return count % 2 === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Length of the backtick run starting at `i` (0 if `text[i]` is not a tick). */
|
|
||||||
function backtickRun(text: string, i: number): number {
|
|
||||||
let count = 0;
|
|
||||||
while (text[i + count] === "`") count += 1;
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to find the closing delimiter for the opening one at `i` (2 chars,
|
|
||||||
* already verified unescaped). The close scan stops at inline code spans and
|
|
||||||
* fence starts (barriers — math never contains them), and returns -1 when the
|
|
||||||
* delimiter is unbalanced (mid-stream chunk).
|
|
||||||
*/
|
|
||||||
function findClose(
|
|
||||||
text: string,
|
|
||||||
from: number,
|
|
||||||
close: string,
|
|
||||||
): number {
|
|
||||||
for (let j = from; j < text.length - 1; j++) {
|
|
||||||
if (text[j] === "`") return -1; // inline code barrier
|
|
||||||
if (
|
|
||||||
text[j] === "\\" &&
|
|
||||||
text[j + 1] === close[1] &&
|
|
||||||
!isEscaped(text, j)
|
|
||||||
) {
|
|
||||||
return j;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when `i` sits at the start of a line (position 0 or right after \n). */
|
|
||||||
function atLineStart(text: string, i: number): boolean {
|
|
||||||
return i === 0 || text[i - 1] === "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy a fenced code block starting at `i` (line start, fence regex matched)
|
|
||||||
* verbatim to the output. Returns the index after the closing fence line —
|
|
||||||
* or the end of the text when the fence is unclosed (mid-stream: everything
|
|
||||||
* after is literal code until the chunk completes).
|
|
||||||
*/
|
|
||||||
function copyFencedBlock(text: string, i: number, out: string[]): number {
|
|
||||||
const open = FENCE_OPEN_RE.exec(text.slice(i));
|
|
||||||
const marker = open?.[1] ?? "```";
|
|
||||||
const closeRe = new RegExp(`^ {0,3}\\${marker[0]}{${marker.length},}[ \\t]*$`);
|
|
||||||
let j = i;
|
|
||||||
// Copy lines until the matching closing fence.
|
|
||||||
while (j < text.length) {
|
|
||||||
const lineEnd = text.indexOf("\n", j);
|
|
||||||
const next = lineEnd === -1 ? text.length : lineEnd + 1;
|
|
||||||
const line = text.slice(j, lineEnd === -1 ? text.length : lineEnd);
|
|
||||||
out.push(text.slice(j, next));
|
|
||||||
if (j > i && closeRe.test(line)) return next; // closing fence line
|
|
||||||
j = next;
|
|
||||||
}
|
|
||||||
return text.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Is the `\[` at `i` block-positioned (own line, only whitespace before)? */
|
|
||||||
function displayIsBlockPositioned(text: string, i: number): boolean {
|
|
||||||
for (let j = i - 1; j >= 0; j--) {
|
|
||||||
const ch = text[j];
|
|
||||||
if (ch === "\n") return true;
|
|
||||||
if (ch !== " " && ch !== "\t") return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Is the `\[` at `i` indented (whitespace before it on the line)? */
|
|
||||||
function displayIsIndented(text: string, i: number): boolean {
|
|
||||||
let sawWhitespace = false;
|
|
||||||
for (let j = i - 1; j >= 0; j--) {
|
|
||||||
const ch = text[j];
|
|
||||||
if (ch === "\n") return sawWhitespace;
|
|
||||||
if (ch !== " " && ch !== "\t") return false; // mid-paragraph
|
|
||||||
sawWhitespace = true;
|
|
||||||
}
|
|
||||||
return sawWhitespace;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Does the closing delimiter end its line (only \n or EOF after it)? */
|
|
||||||
function closeEndsLine(text: string, afterClose: number): boolean {
|
|
||||||
return afterClose >= text.length || text[afterClose] === "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rewrite balanced `\(...\)` → `$$...$$` and `\[...\]` → `$$\n...\n$$`.
|
|
||||||
* Everything else passes through byte-for-byte.
|
|
||||||
*/
|
|
||||||
/** The escaped-dollar-aware inline closer (owner, 2026-10-12): micromark's
|
|
||||||
* mathText closer is code-span logic — ANY size-matched `$` run closes,
|
|
||||||
* `\$` included, so `$x = \$5$` closes at the `\$` and errors. KaTeX's
|
|
||||||
* own auto-render scanner skips escaped dollars, and KaTeX renders `\$`
|
|
||||||
* fine — the author's intent is unambiguous: an escaped dollar (odd number
|
|
||||||
* of backslashes before it) never closes the span. Mirrors findDollarClose's
|
|
||||||
* inline rules otherwise (exact size match, whole-run skip). */
|
|
||||||
function findInlineDollarCloseEscapeAware(text: string, from: number): number {
|
|
||||||
for (let j = from; j < text.length;) {
|
|
||||||
if (text[j] !== "$") {
|
|
||||||
j += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isEscaped(text, j)) {
|
|
||||||
j += 1;
|
|
||||||
continue; // `\$` never closes
|
|
||||||
}
|
|
||||||
const count = dollarRun(text, j);
|
|
||||||
if (count === 1) return j;
|
|
||||||
j += count;
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Replace `\$` (odd-backslash dollars) inside a math span with the
|
|
||||||
* dollar-free `\text{\textdollar}` — KaTeX-safe in math mode (bare
|
|
||||||
* `\textdollar` is text-mode-only there). Even-backslash dollars (`\\$`,
|
|
||||||
* row break + dollar) are left alone. */
|
|
||||||
function replaceEscapedDollars(content: string): string {
|
|
||||||
let replaced = "";
|
|
||||||
for (let j = 0; j < content.length;) {
|
|
||||||
if (content[j] === "\\" && content[j + 1] === "$" && !isEscaped(content, j)) {
|
|
||||||
replaced += "\\text{\\textdollar}";
|
|
||||||
j += 2;
|
|
||||||
} else {
|
|
||||||
replaced += content[j];
|
|
||||||
j += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return replaced;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function transformMathDelimiters(markdown: string): string {
|
|
||||||
const out: string[] = [];
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < markdown.length) {
|
|
||||||
const ch = markdown[i]!;
|
|
||||||
|
|
||||||
// Fenced code blocks: copied verbatim (only detectable at line start).
|
|
||||||
if (atLineStart(markdown, i) && FENCE_OPEN_RE.test(markdown.slice(i))) {
|
|
||||||
i = copyFencedBlock(markdown, i, out);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inline code spans: copied verbatim.
|
|
||||||
if (ch === "`") {
|
|
||||||
const run = backtickRun(markdown, i);
|
|
||||||
let j = i + run;
|
|
||||||
// Find the matching run (same length, per CommonMark).
|
|
||||||
while (j < markdown.length) {
|
|
||||||
const nextRun = backtickRun(markdown, j);
|
|
||||||
if (nextRun === run) break;
|
|
||||||
j += Math.max(nextRun, 1);
|
|
||||||
}
|
|
||||||
if (j < markdown.length) {
|
|
||||||
out.push(markdown.slice(i, j + run));
|
|
||||||
i = j + run;
|
|
||||||
} else {
|
|
||||||
// Unclosed run (mid-stream): literal, keep scanning after it.
|
|
||||||
out.push(markdown.slice(i, i + run));
|
|
||||||
i += run;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Math delimiters.
|
|
||||||
if (ch === "\\" && !isEscaped(markdown, i)) {
|
|
||||||
const next = markdown[i + 1];
|
|
||||||
if (next === "(" || next === "[") {
|
|
||||||
const isDisplay = next === "[";
|
|
||||||
const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN;
|
|
||||||
const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE;
|
|
||||||
const closeAt = findClose(markdown, i + open.length, close);
|
|
||||||
if (closeAt !== -1) {
|
|
||||||
const content = markdown.slice(i + open.length, closeAt).trim();
|
|
||||||
if (content.length > 0) {
|
|
||||||
if (isDisplay && displayIsIndented(markdown, i)) {
|
|
||||||
// Indented display math (inside a list item, quote, …):
|
|
||||||
// an indented $$ flow block half-parses inside list
|
|
||||||
// continuations (empty math, eaten items), so degrade to
|
|
||||||
// the inline form — renders inline, stays inside the item.
|
|
||||||
// mathText cannot span line endings, so collapse them.
|
|
||||||
out.push(`$$${content.replace(/\s*\n\s*/g, " ")}$$`);
|
|
||||||
} else if (isDisplay) {
|
|
||||||
// Flow math ($$ alone on its lines) renders as display
|
|
||||||
// KaTeX. Mid-paragraph \[...\] (or trailing text after the
|
|
||||||
// closer) would half-parse into literal dollars + KaTeX
|
|
||||||
// errors — pad with blank lines to force a clean block.
|
|
||||||
// Block-positioned math passes through in place.
|
|
||||||
const afterClose = closeAt + close.length;
|
|
||||||
const padBefore =
|
|
||||||
displayIsBlockPositioned(markdown, i) ? "" : "\n\n";
|
|
||||||
const padAfter =
|
|
||||||
closeEndsLine(markdown, afterClose) ? "" : "\n\n";
|
|
||||||
out.push(`${padBefore}$$\n${content}\n$$${padAfter}`);
|
|
||||||
} else {
|
|
||||||
// Preserve the legacy reader's two-dollar inline output
|
|
||||||
// (renders inline, not display).
|
|
||||||
out.push(`$$${content}$$`);
|
|
||||||
}
|
|
||||||
i = closeAt + close.length;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Unbalanced or empty: leave both characters untouched.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escaped dollars inside inline math (owner, 2026-10-12): normalize
|
|
||||||
// BEFORE tokenization — inside a balanced inline span, `\$` becomes the
|
|
||||||
// dollar-free `\text{\textdollar}` so the scanner's closer is the one
|
|
||||||
// the author meant (micromark would otherwise close at the `\$`, error
|
|
||||||
// out, and cascade). Display runs are copied whole and left untouched —
|
|
||||||
// a lone `$` can't close `$$`, so `\$` already survives there — and
|
|
||||||
// copying the run stops its second `$` posing as an inline opener.
|
|
||||||
// Spans without escaped dollars emit byte-identical text; prose `\$`,
|
|
||||||
// code, and unbalanced spans are untouched.
|
|
||||||
if (ch === "$" && !isEscaped(markdown, i)) {
|
|
||||||
const run = dollarRun(markdown, i);
|
|
||||||
if (run === 1) {
|
|
||||||
const closeAt = findInlineDollarCloseEscapeAware(markdown, i + 1);
|
|
||||||
if (closeAt !== -1) {
|
|
||||||
const content = markdown.slice(i + 1, closeAt);
|
|
||||||
const replaced = replaceEscapedDollars(content);
|
|
||||||
if (replaced !== content) {
|
|
||||||
out.push(`$${replaced}$`);
|
|
||||||
i = closeAt + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
out.push(markdown.slice(i, i + run));
|
|
||||||
i += run;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out.push(ch);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return out.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LaTeX-escape restoration (owner ruling 2026-09-26) — the JSON-debris fix.
|
|
||||||
*
|
|
||||||
* When a model emits LaTeX with SINGLE backslashes inside JSON tool-call
|
|
||||||
* arguments (`\forall`, not the properly escaped `\\forall`), JSON escape
|
|
||||||
* resolution eats the command's first letter: `\f` → form feed + "orall",
|
|
||||||
* `\v` → vertical tab + "arphi", `\t` → tab + "o". The debris survives into
|
|
||||||
* the persisted RAW args and renders as garbage. Inside a MATH SPAN these
|
|
||||||
* control chars can never be legitimate content, so restoring the two-char
|
|
||||||
* escape reconstructs the model's intent byte-perfectly. Outside spans the
|
|
||||||
* same chars might be real prose (pasted tables, formatted text), so only
|
|
||||||
* balanced math spans are touched. The legacy `\(…\)` / `\[…\]` pass stays
|
|
||||||
* unchanged; the dollar reader adds `$…$` and fenced `$$…$$` recognition,
|
|
||||||
* excluding code and escaped opening dollars. Unbalanced spans pass through.
|
|
||||||
*
|
|
||||||
* ASYMMETRIC NEWLINE RULE (owner ruling 2026-09-27, Option A): inline spans
|
|
||||||
* restore all six debris chars — real newlines/tabs inside INLINE math
|
|
||||||
* essentially never occur (options and inline math are single-line by
|
|
||||||
* nature). Display spans restore five and KEEP real newlines — multi-line
|
|
||||||
* display math (aligned environments, equation systems) is common, and a
|
|
||||||
* real line break is byte-identical on the wire to sloppy `\neq`/`\nu`/
|
|
||||||
* `\neg`/`\nabla` (JSON `\n` resolves to a newline either way). Corrupting
|
|
||||||
* legitimate content is strictly worse than failing to repair that rarest
|
|
||||||
* slice, so display-span n-command sloppiness stays broken by design.
|
|
||||||
*/
|
|
||||||
const ESCAPE_DEBRIS: Record<string, string> = {
|
|
||||||
"\b": "\\b",
|
|
||||||
"\t": "\\t",
|
|
||||||
"\n": "\\n",
|
|
||||||
"\v": "\\v",
|
|
||||||
"\f": "\\f",
|
|
||||||
"\r": "\\r",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEBRIS_RE = /[\b\t\n\v\f\r]/;
|
/** Preserve the legacy list-continuation policy: indented display math
|
||||||
|
* degrades to inline rather than disrupting list structure. Literal code
|
||||||
/** Display spans keep real newlines (multi-line equations) — debris there is
|
* indentation is already protected by the shared reader. */
|
||||||
* everything except \n (a lone real \r never occurs in authored text, and
|
function indented(text: string, i: number): boolean {
|
||||||
* `\right`/`\rho`/`\rangle` corruption is common in display math). */
|
const prefix = text.slice(text.lastIndexOf("\n", i - 1) + 1, i);
|
||||||
const DISPLAY_DEBRIS_RE = /[\b\t\v\f\r]/;
|
return prefix.length > 0 && /^[ \t]+$/.test(prefix);
|
||||||
|
}
|
||||||
/** Shared display-only repair. Keep raw arguments and model history unchanged. */
|
function blockPosition(text: string, i: number): boolean {
|
||||||
export function repairMathForDisplay(text: string): string {
|
return /^[ \t]*$/.test(text.slice(text.lastIndexOf("\n", i - 1) + 1, i));
|
||||||
return healOverescapedCommands(restoreMathEscapes(text));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** micromark closes native single-dollar spans at escaped dollars too.
|
||||||
|
* Replace an intended literal dollar with equivalent dollar-free TeX before
|
||||||
|
* tokenization. Code, prose, unbalanced spans and display dollars stay raw. */
|
||||||
|
function replaceEscapedDollars(content: string): string {
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < content.length; i++) {
|
||||||
|
if (content[i] === "\\" && content[i + 1] === "$" && !escaped(content, i)) {
|
||||||
|
out += "\\text{\\textdollar}"; i++;
|
||||||
|
} else out += content[i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read old \(...\)/\[...\] syntax without touching literal content or
|
||||||
|
* reparsing native math as Markdown. Dollar authoring remains native. */
|
||||||
|
export function transformMathDelimiters(markdown: string): string {
|
||||||
|
let offset = 0;
|
||||||
|
return readContentSpans(markdown).map(span => {
|
||||||
|
const start = offset; offset += span.raw.length;
|
||||||
|
if (span.kind !== "math") return span.raw;
|
||||||
|
if (span.open === "$") return "$" + replaceEscapedDollars(span.body) + span.close;
|
||||||
|
if (span.open !== "\\(" && span.open !== "\\[") return span.raw;
|
||||||
|
const body = span.body.trim();
|
||||||
|
if (!body) return span.raw;
|
||||||
|
if (span.open === "\\(") return `$$${body}$$`;
|
||||||
|
if (indented(markdown, start)) return `$$${body.replace(/\s*\n\s*/g, " ")}$$`;
|
||||||
|
const before = blockPosition(markdown, start) ? "" : "\n\n";
|
||||||
|
const end = start + span.raw.length;
|
||||||
|
const after = end === markdown.length || markdown[end] === "\n" ? "" : "\n\n";
|
||||||
|
return `${before}$$\n${body}\n$$${after}`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBRIS: Record<string, string> = {
|
||||||
|
"\b": "\\b", "\t": "\\t", "\v": "\\v", "\f": "\\f", "\r": "\\r",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Recover JSON-swallowed command prefixes only in recognized math, never
|
||||||
|
* in code or genuine TeX comments. Restore all debris in one pass. Physical
|
||||||
|
* display newlines and CRLF remain source whitespace. Inline LF recovery is
|
||||||
|
* limited to complete known n-command names. Under the approved typical-
|
||||||
|
* model-authoring policy this includes newline+u→nu in inline math only;
|
||||||
|
* already-correct commands are never decoded into whitespace. */
|
||||||
export function restoreMathEscapes(text: string): string {
|
export function restoreMathEscapes(text: string): string {
|
||||||
if (!DEBRIS_RE.test(text)) return text;
|
if (!/[\b\t\n\v\f\r]/.test(text)) return text;
|
||||||
return restoreDollarMathEscapes(restoreLegacyMathEscapes(text));
|
return mapMathContent(text, (body, display) => outsideTexComments(body, part => {
|
||||||
|
const restored = part.replace(/[\b\t\v\f\r]/g, (char, offset: number) => {
|
||||||
|
if (char === "\r" && part[offset + 1] === "\n") return char;
|
||||||
|
const suffix = /^[A-Za-z]+/.exec(part.slice(offset + 1))?.[0];
|
||||||
|
return suffix && knownTexCommand(DEBRIS[char]!.slice(1) + suffix) ? DEBRIS[char]! : char;
|
||||||
|
});
|
||||||
|
if (display) return restored;
|
||||||
|
return restored.replace(/\n([A-Za-z]+)/g, (whole, suffix: string) =>
|
||||||
|
knownTexCommand("n" + suffix) ? "\\n" + suffix : whole);
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep legacy recovery byte-for-byte; only the recognized syntax expands. */
|
const commandSymbol = /[,;:!|{}%]/;
|
||||||
function restoreLegacyMathEscapes(text: string): string {
|
|
||||||
if (!DEBRIS_RE.test(text)) return text;
|
|
||||||
const out: string[] = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < text.length) {
|
|
||||||
const ch = text[i]!;
|
|
||||||
if (ch === "\\" && !isEscaped(text, i)) {
|
|
||||||
const next = text[i + 1];
|
|
||||||
if (next === "(" || next === "[") {
|
|
||||||
const isDisplay = next === "[";
|
|
||||||
const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN;
|
|
||||||
const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE;
|
|
||||||
const closeAt = findClose(text, i + open.length, close);
|
|
||||||
if (closeAt !== -1) {
|
|
||||||
const inner = text.slice(i + open.length, closeAt);
|
|
||||||
const debris = isDisplay ? DISPLAY_DEBRIS_RE : DEBRIS_RE;
|
|
||||||
out.push(open + inner.replace(debris, (c) => ESCAPE_DEBRIS[c]!) + close);
|
|
||||||
i = closeAt + close.length;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.push(ch);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
return out.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function dollarRun(text: string, i: number): number {
|
/** Decode prefixes and row separators according to ORIGINAL TeX context.
|
||||||
let count = 0;
|
* A native pair is literal; an encoded pair supplies a factor, so an encoded
|
||||||
while (text[i + count] === "$") count += 1;
|
* four-backslash row becomes TWO, never a command prefix. Unknown commands
|
||||||
return count;
|
* without that structural evidence are left alone. */
|
||||||
}
|
function recoverCommandRuns(inner: string, display: boolean): string {
|
||||||
|
const ranges = texRanges(inner);
|
||||||
/** Native math fences start at a line's block position; one-line $$…$$
|
const recovered = inner.replace(/\\+/g, (run: string, offset: number) => {
|
||||||
* remains inline. A flow opener may have metadata, but never another $. */
|
const contexts = ranges.filter(r => offset >= r.start && offset < r.end);
|
||||||
function dollarIsDisplay(text: string, i: number, size: number): boolean {
|
if (contexts.some(r => r.kind === "row" || r.kind === "comment") || run.length % 2 !== 0) return run;
|
||||||
if (size < 2 || !displayIsBlockPositioned(text, i)) return false;
|
const next = inner[offset + run.length] ?? "";
|
||||||
const end = text.indexOf("\n", i + size);
|
const encoded = contexts.filter(r => r.kind === "encoded").at(-1);
|
||||||
return !text.slice(i + size, end === -1 ? text.length : end).includes("$");
|
const word = /^[A-Za-z]+/.exec(inner.slice(offset + run.length))?.[0];
|
||||||
}
|
if (encoded) {
|
||||||
|
const factor = encoded.factor!;
|
||||||
/** Match micromark's delimiter runs: exact size inline, at least the
|
const rows = run.length % (2 * factor) === 0;
|
||||||
* opener's size on an otherwise-empty line for display. Inside math,
|
const command = word ? knownTexCommand(word) : commandSymbol.test(next);
|
||||||
* backslashes are TeX data, not Markdown escapes for closing dollars. */
|
if (run.length % factor === 0 && (rows || command)) return "\\".repeat(run.length / factor);
|
||||||
function findDollarClose(
|
return run;
|
||||||
text: string,
|
|
||||||
from: number,
|
|
||||||
size: number,
|
|
||||||
isDisplay: boolean,
|
|
||||||
): { at: number; after: number } | undefined {
|
|
||||||
if (isDisplay) {
|
|
||||||
let lineEnd = text.indexOf("\n", from);
|
|
||||||
while (lineEnd !== -1) {
|
|
||||||
const start = lineEnd + 1;
|
|
||||||
lineEnd = text.indexOf("\n", start);
|
|
||||||
const line = text.slice(start, lineEnd === -1 ? text.length : lineEnd);
|
|
||||||
const close = /^([ \t]{0,3})(\$+)[ \t]*\r?$/.exec(line);
|
|
||||||
if (close && close[2]!.length >= size) {
|
|
||||||
const at = start + close[1]!.length;
|
|
||||||
return { at, after: at + close[2]!.length };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return undefined;
|
if (next === "%") return numericPercentEscape(inner, offset + run.length) ? "\\" : run;
|
||||||
}
|
if (word) return word.length > 1 && knownTexCommand(word) ? "\\" : run;
|
||||||
for (let j = from; j < text.length;) {
|
return next && commandSymbol.test(next) ? "\\" : run;
|
||||||
if (text[j] !== "$") {
|
});
|
||||||
j += 1;
|
// Structural evidence is primary (parse success cannot prove row
|
||||||
continue;
|
// preservation). As an additional guard, never turn parseable source into
|
||||||
}
|
// a parse failure through partial command recovery.
|
||||||
const count = dollarRun(text, j);
|
if (recovered !== inner && !canParseTex(recovered, display) && canParseTex(inner, display)) return inner;
|
||||||
if (count === size) return { at: j, after: j + count };
|
return recovered;
|
||||||
j += count;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Dollar recognition only — the same six/five-character repair maps,
|
|
||||||
* with no over-escaping normalization or mathematical validation. */
|
|
||||||
function restoreDollarMathEscapes(text: string): string {
|
|
||||||
if (!DEBRIS_RE.test(text) || !text.includes("$")) return text;
|
|
||||||
const out: string[] = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < text.length) {
|
|
||||||
if (atLineStart(text, i) && FENCE_OPEN_RE.test(text.slice(i))) {
|
|
||||||
i = copyFencedBlock(text, i, out);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (text[i] === "`") {
|
|
||||||
const run = backtickRun(text, i);
|
|
||||||
let j = i + run;
|
|
||||||
while (j < text.length) {
|
|
||||||
const nextRun = backtickRun(text, j);
|
|
||||||
if (nextRun === run) break;
|
|
||||||
j += Math.max(nextRun, 1);
|
|
||||||
}
|
|
||||||
const after = j < text.length ? j + run : i + run;
|
|
||||||
out.push(text.slice(i, after));
|
|
||||||
i = after;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Legacy spans have already received their own inline/display policy.
|
|
||||||
// Do not reinterpret dollars inside them in this second pass.
|
|
||||||
if (text[i] === "\\" && !isEscaped(text, i)) {
|
|
||||||
const next = text[i + 1];
|
|
||||||
if (next === "(" || next === "[") {
|
|
||||||
const closeAt = findClose(text, i + 2, next === "[" ? DISPLAY_CLOSE : INLINE_CLOSE);
|
|
||||||
if (closeAt !== -1) {
|
|
||||||
out.push(text.slice(i, closeAt + 2));
|
|
||||||
i = closeAt + 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (text[i] === "$" && !isEscaped(text, i)) {
|
|
||||||
const size = dollarRun(text, i);
|
|
||||||
const isDisplay = dollarIsDisplay(text, i, size);
|
|
||||||
const close = findDollarClose(text, i + size, size, isDisplay);
|
|
||||||
if (close) {
|
|
||||||
const debris = isDisplay ? DISPLAY_DEBRIS_RE : DEBRIS_RE;
|
|
||||||
out.push(
|
|
||||||
text.slice(i, i + size) +
|
|
||||||
text.slice(i + size, close.at).replace(debris, (c) => ESCAPE_DEBRIS[c]!) +
|
|
||||||
text.slice(close.at, close.after),
|
|
||||||
);
|
|
||||||
i = close.after;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out.push(text.slice(i, i + size));
|
|
||||||
i += size;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out.push(text[i]!);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
return out.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Over-escaped command heal (owner ruling 2026-10-15) — the doubled-
|
|
||||||
* backslash fix.
|
|
||||||
*
|
|
||||||
* Some models stochastically emit DOUBLED backslashes inside JSON tool-call
|
|
||||||
* arguments: the saved record holds `\\nabla` (two real backslashes) where
|
|
||||||
* `\nabla` was meant. KaTeX reads the `\\` as a row break and then renders
|
|
||||||
* the command name as plain letters — the learner sees "the word nabla, not
|
|
||||||
* the symbol". Field evidence (2026-10-15, real data): xiaomi/mimo-v2.6-pro
|
|
||||||
* doubled per-call, per-field (one probe's question doubled while its
|
|
||||||
* explanation and options in the SAME call stayed clean — authoring, not
|
|
||||||
* transport), and deepseek-v4.1-flash once; quadrupling appeared when the
|
|
||||||
* in-session agent "compensated" for its own doubled first attempt.
|
|
||||||
*
|
|
||||||
* THE RULE. Inside a balanced math span ONLY (legacy `\(…\)`/`\[…\]` and
|
|
||||||
* dollar `$…$`/`$$…$$`, code spans and fences excluded), an EVEN-length
|
|
||||||
* backslash run (2, 4, …) immediately followed by a command start — a letter
|
|
||||||
* (any command name; bare letters after a row break are never authored
|
|
||||||
* content) or a punctuation command (`,` `;` `:` `!` `|` — thin spaces,
|
|
||||||
* norm bars) — collapses to a single backslash. Even runs before anything
|
|
||||||
* else (space, newline, `&`, `[`, `$`, digits) are legitimate row breaks and
|
|
||||||
* stay; ODD runs (3, 5, …) are row break(s) plus a real command with its own
|
|
||||||
* backslash — e.g. matrix cells written `a \\\nabla` — and stay untouched,
|
|
||||||
* so matrix content cannot be corrupted. Outside spans nothing is touched:
|
|
||||||
* doubled backslashes in prose or code are not ours to judge. Unbalanced
|
|
||||||
* spans pass through, exactly like the debris passes.
|
|
||||||
*/
|
|
||||||
const COMMAND_START_CLASS = "[A-Za-z,;:!|]";
|
|
||||||
|
|
||||||
/** Collapse even backslash runs before a command start; keep odd runs. */
|
|
||||||
function halveOverescapedCommandRuns(inner: string): string {
|
|
||||||
return inner.replace(
|
|
||||||
new RegExp("(\\\\+)(?=" + COMMAND_START_CLASS + ")", "g"),
|
|
||||||
(run: string) => (run.length % 2 === 0 ? "\\" : run),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function healOverescapedCommands(text: string): string {
|
export function healOverescapedCommands(text: string): string {
|
||||||
if (!text.includes("\\\\")) return text;
|
return text.includes("\\\\") ? mapMathContent(text, recoverCommandRuns) : text;
|
||||||
const out: string[] = [];
|
}
|
||||||
let i = 0;
|
|
||||||
while (i < text.length) {
|
/** Presentation-only, language-independent recovery. Shape parsing, saved
|
||||||
if (atLineStart(text, i) && FENCE_OPEN_RE.test(text.slice(i))) {
|
* identities, grading and model history must use the unprojected source. */
|
||||||
i = copyFencedBlock(text, i, out);
|
export function repairMathForDisplay(text: string): string {
|
||||||
continue;
|
return healOverescapedCommands(restoreMathEscapes(recoverDocumentLayout(text)));
|
||||||
}
|
|
||||||
if (text[i] === "`") {
|
|
||||||
const run = backtickRun(text, i);
|
|
||||||
let j = i + run;
|
|
||||||
while (j < text.length) {
|
|
||||||
const nextRun = backtickRun(text, j);
|
|
||||||
if (nextRun === run) break;
|
|
||||||
j += Math.max(nextRun, 1);
|
|
||||||
}
|
|
||||||
const after = j < text.length ? j + run : i + run;
|
|
||||||
out.push(text.slice(i, after));
|
|
||||||
i = after;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (text[i] === "\\" && !isEscaped(text, i)) {
|
|
||||||
const next = text[i + 1];
|
|
||||||
if (next === "(" || next === "[") {
|
|
||||||
const isDisplay = next === "[";
|
|
||||||
const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN;
|
|
||||||
const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE;
|
|
||||||
const closeAt = findClose(text, i + open.length, close);
|
|
||||||
if (closeAt !== -1) {
|
|
||||||
const inner = text.slice(i + open.length, closeAt);
|
|
||||||
out.push(open + halveOverescapedCommandRuns(inner) + close);
|
|
||||||
i = closeAt + close.length;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (text[i] === "$" && !isEscaped(text, i)) {
|
|
||||||
const size = dollarRun(text, i);
|
|
||||||
const isDisplay = dollarIsDisplay(text, i, size);
|
|
||||||
const close = findDollarClose(text, i + size, size, isDisplay);
|
|
||||||
if (close) {
|
|
||||||
out.push(
|
|
||||||
text.slice(i, i + size) +
|
|
||||||
halveOverescapedCommandRuns(text.slice(i + size, close.at)) +
|
|
||||||
text.slice(close.at, close.after),
|
|
||||||
);
|
|
||||||
i = close.after;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out.push(text.slice(i, i + size));
|
|
||||||
i += size;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out.push(text[i]!);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
return out.join("");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,55 @@ export function asAgentMessages(messages: readonly LhMessage[]): AgentMessage[]
|
|||||||
return messages as unknown as AgentMessage[];
|
return messages as unknown as AgentMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── explicit learner-facing output ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export const SEND_TO_USER_TOOL = "send_to_user";
|
||||||
|
|
||||||
|
/** Read the output tool's message without changing the native call. Also
|
||||||
|
* accepts partial arguments while streaming; malformed/empty inputs carry
|
||||||
|
* no prose. This projection is independent of the currently selected model. */
|
||||||
|
export function sendToUserText(block: unknown): string | undefined {
|
||||||
|
if (!block || typeof block !== "object") return undefined;
|
||||||
|
const call = block as { type?: unknown; name?: unknown; arguments?: unknown };
|
||||||
|
if (call.type !== "toolCall" || call.name !== SEND_TO_USER_TOOL) return undefined;
|
||||||
|
if (!call.arguments || typeof call.arguments !== "object") return undefined;
|
||||||
|
const message = (call.arguments as { message?: unknown }).message;
|
||||||
|
return typeof message === "string" && message.trim() ? message : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Learner-facing prose, in content order. Only the explicit output tool
|
||||||
|
* joins ordinary text here — thinking and all other tool inputs stay out.
|
||||||
|
* Shared by coverage/extraction and Markdown; native history stays intact. */
|
||||||
|
export function userFacingTextOf(content: unknown, failedOutputCalls?: ReadonlySet<string>): string[] {
|
||||||
|
if (typeof content === "string") return content.trim() ? [content] : [];
|
||||||
|
if (!Array.isArray(content)) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const block of content) {
|
||||||
|
if (!block || typeof block !== "object") continue;
|
||||||
|
if (block.type === "toolCall" && failedOutputCalls?.has(block.id)) continue;
|
||||||
|
const text = block.type === "text" ? block.text : sendToUserText(block);
|
||||||
|
if (typeof text === "string" && text.trim()) out.push(text);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Failed output calls remain retry context, not delivered teaching.
|
||||||
|
* Truncated calls can have valid-looking partial arguments. Failed/aborted
|
||||||
|
* generations do not execute their calls at all, so may have no result. */
|
||||||
|
export function failedOutputCallIds(messages: readonly LhMessage[]): ReadonlySet<string> {
|
||||||
|
const failed = new Set<string>();
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.role === "toolResult" && message.toolName === SEND_TO_USER_TOOL && message.isError) {
|
||||||
|
failed.add(message.toolCallId);
|
||||||
|
} else if (message.role === "assistant" && (message.stopReason === "error" || message.stopReason === "aborted")) {
|
||||||
|
for (const block of message.content) {
|
||||||
|
if (block.type === "toolCall" && block.name === SEND_TO_USER_TOOL) failed.add(block.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── id minting ──────────────────────────────────────────────────────────────
|
// ─── id minting ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** The app-wide message id shape (same as the UIMessage-era mint). */
|
/** The app-wide message id shape (same as the UIMessage-era mint). */
|
||||||
@@ -513,8 +562,9 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
|
|||||||
// THE COMPACTION CUT (owner round 14, 2026-10-05): the ACTIVE marker
|
// THE COMPACTION CUT (owner round 14, 2026-10-05): the ACTIVE marker
|
||||||
// (the newest — chained compactions rule last) names the first kept
|
// (the newest — chained compactions rule last) names the first kept
|
||||||
// message; everything before it is replaced by the marker's content
|
// message; everything before it is replaced by the marker's content
|
||||||
// (header + summary) rendered ONCE, up top. The marker itself is then
|
// (header + summary) rendered ONCE, up top. All compaction markers
|
||||||
// skipped in place — it must never render twice. A cut naming a
|
// in the retained tail are then skipped, including older summaries
|
||||||
|
// whose stored positions still lie after the cut. A cut naming a
|
||||||
// missing id degrades honestly (everything renders — never throw).
|
// missing id degrades honestly (everything renders — never throw).
|
||||||
const lhMessages = messages as readonly LhMessage[];
|
const lhMessages = messages as readonly LhMessage[];
|
||||||
const active = activeCompactionOf(lhMessages);
|
const active = activeCompactionOf(lhMessages);
|
||||||
@@ -555,11 +605,10 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
|
|||||||
// UI-only markers: plan blocks, and problem dismissals (the
|
// UI-only markers: plan blocks, and problem dismissals (the
|
||||||
// synthesized conclude pair carries the model's face).
|
// synthesized conclude pair carries the model's face).
|
||||||
if (marker.customType === "plan-block" || marker.customType === "problem-dismissed") continue;
|
if (marker.customType === "plan-block" || marker.customType === "problem-dismissed") continue;
|
||||||
// The ACTIVE compaction marker already rendered as the head —
|
// The head supersedes every compaction marker, not only its own
|
||||||
// never twice. (Stale markers before the cut are unreachable; a
|
// stored position. Without a resolved head, preserve the existing
|
||||||
// marker whose cut could not resolve has no head and renders as
|
// full-history fallback, including the summary text.
|
||||||
// its user text — honest degradation.)
|
if (marker.customType === "compaction" && head !== null) continue;
|
||||||
if (marker.customType === "compaction" && head !== null && marker.id === active!.id) continue;
|
|
||||||
out.push({
|
out.push({
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [{ type: "text", text: marker.content }],
|
content: [{ type: "text", text: marker.content }],
|
||||||
|
|||||||
@@ -136,6 +136,8 @@ export interface CreateJobInit {
|
|||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
/** First activity line (defaults to "starting"). */
|
/** First activity line (defaults to "starting"). */
|
||||||
activity?: string;
|
activity?: string;
|
||||||
|
/** Saved on the job before its worker is spawned. */
|
||||||
|
input?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mergeable fields — identity (id, kind, chatId, toolCallId, createdAt) is immutable. */
|
/** Mergeable fields — identity (id, kind, chatId, toolCallId, createdAt) is immutable. */
|
||||||
|
|||||||
@@ -511,6 +511,21 @@ export interface DockSelection {
|
|||||||
|
|
||||||
export const emptyDockSelection = (): DockSelection => ({ selected: new Set(), otherSelected: false, dontKnow: false });
|
export const emptyDockSelection = (): DockSelection => ({ selected: new Set(), otherSelected: false, dontKnow: false });
|
||||||
|
|
||||||
|
/** In-memory submission snapshot: survives the question form's optimistic
|
||||||
|
* unmount, never written to storage. Indices use the call's stable shuffle. */
|
||||||
|
export interface QuestionFormDraft {
|
||||||
|
selection: DockSelection;
|
||||||
|
thinking: string;
|
||||||
|
other: string;
|
||||||
|
text: string;
|
||||||
|
confidenceIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestionDraft {
|
||||||
|
form?: QuestionFormDraft;
|
||||||
|
readingThinking?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggling a listed option. Single-select: the option becomes THE answer and
|
* Toggling a listed option. Single-select: the option becomes THE answer and
|
||||||
* displaces "Other". Multi-select: the option toggles alongside its siblings
|
* displaces "Other". Multi-select: the option toggles alongside its siblings
|
||||||
|
|||||||
@@ -22,22 +22,15 @@
|
|||||||
|
|
||||||
import type { LhMessage } from "./native-messages.ts";
|
import type { LhMessage } from "./native-messages.ts";
|
||||||
import {
|
import {
|
||||||
|
failedOutputCallIds,
|
||||||
PRACTICE_CONCLUDE_TOOL,
|
PRACTICE_CONCLUDE_TOOL,
|
||||||
PRACTICE_PROBLEM_TOOL,
|
PRACTICE_PROBLEM_TOOL,
|
||||||
problemInputForConclude,
|
problemInputForConclude,
|
||||||
|
SEND_TO_USER_TOOL,
|
||||||
|
userFacingTextOf,
|
||||||
} from "./native-messages.ts";
|
} from "./native-messages.ts";
|
||||||
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
|
|
||||||
/** The user-facing text blocks of a native message (string or text parts). */
|
|
||||||
function textOf(content: unknown): string[] {
|
|
||||||
if (typeof content === "string") return content.trim() ? [content] : [];
|
|
||||||
if (!Array.isArray(content)) return [];
|
|
||||||
return content
|
|
||||||
.filter((c): c is { type: "text"; text: string } => (c as { type?: string }).type === "text")
|
|
||||||
.map((c) => c.text)
|
|
||||||
.filter((t) => t.trim().length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The practice join context (owner-approved, 2026-10-06 export round):
|
/** The practice join context (owner-approved, 2026-10-06 export round):
|
||||||
* practice tool RESULTS are acks — the statement, outcome, and verdict
|
* practice tool RESULTS are acks — the statement, outcome, and verdict
|
||||||
* all live in the assistant's call arguments, and the model solution
|
* all live in the assistant's call arguments, and the model solution
|
||||||
@@ -48,6 +41,7 @@ export interface MarkdownContext {
|
|||||||
messages: ReadonlyArray<LhMessage>;
|
messages: ReadonlyArray<LhMessage>;
|
||||||
/** toolCallId → the call part's name + arguments. */
|
/** toolCallId → the call part's name + arguments. */
|
||||||
calls: Map<string, { name: string; args: Record<string, unknown> | undefined }>;
|
calls: Map<string, { name: string; args: Record<string, unknown> | undefined }>;
|
||||||
|
failedOutputCalls?: ReadonlySet<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function markdownContext(messages: ReadonlyArray<LhMessage>): MarkdownContext {
|
function markdownContext(messages: ReadonlyArray<LhMessage>): MarkdownContext {
|
||||||
@@ -66,7 +60,7 @@ function markdownContext(messages: ReadonlyArray<LhMessage>): MarkdownContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { messages, calls };
|
return { messages, calls, failedOutputCalls: failedOutputCallIds(messages) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** solvedWithHints → "Solved with hints" — the outcome enum reads as prose. */
|
/** solvedWithHints → "Solved with hints" — the outcome enum reads as prose. */
|
||||||
@@ -172,16 +166,16 @@ export function messageMarkdown(
|
|||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
if (role === "user") {
|
if (role === "user") {
|
||||||
for (const text of textOf((message as { content: unknown }).content)) {
|
for (const text of userFacingTextOf((message as { content: unknown }).content)) {
|
||||||
parts.push(`### Learner${anchor}\n\n${text.trim()}`);
|
parts.push(`### Learner${anchor}\n\n${text.trim()}`);
|
||||||
}
|
}
|
||||||
} else if (role === "assistant") {
|
} else if (role === "assistant") {
|
||||||
for (const text of textOf((message as { content: unknown }).content)) {
|
for (const text of userFacingTextOf((message as { content: unknown }).content, ctx?.failedOutputCalls ?? failedOutputCallIds([message]))) {
|
||||||
parts.push(`### Teacher${anchor}\n\n${text.trim()}`);
|
parts.push(`### Teacher${anchor}\n\n${text.trim()}`);
|
||||||
}
|
}
|
||||||
} else if (role === "toolResult") {
|
} else if (role === "toolResult") {
|
||||||
const result = message as ToolResultMessage;
|
const result = message as ToolResultMessage;
|
||||||
if (result.isError) return null; // retry context, not learning evidence
|
if (result.isError || result.toolName === SEND_TO_USER_TOOL) return null; // retry context or output ack, not learning evidence
|
||||||
const { heading, body } = toolResultBody(result, ctx);
|
const { heading, body } = toolResultBody(result, ctx);
|
||||||
parts.push(`### ${heading}${anchor}\n\n${body.join("\n\n")}`);
|
parts.push(`### ${heading}${anchor}\n\n${body.join("\n\n")}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ export const ROLE_SLOT: Readonly<Record<ModelRole, ModelSlotName>> = {
|
|||||||
/** General settings — app-level behavior. Version DISPLAY and plans login
|
/** General settings — app-level behavior. Version DISPLAY and plans login
|
||||||
* live outside the record (package.json / auth.json) by owner decision. */
|
* live outside the record (package.json / auth.json) by owner decision. */
|
||||||
export interface GeneralSettings {
|
export interface GeneralSettings {
|
||||||
/** Auto-update the app (default ON). Mechanism ships in a later wave. */
|
/** Automatic startup/24h update checks (default ON). Manual checks bypass this. */
|
||||||
autoUpdate?: boolean;
|
autoUpdate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-26
@@ -30,7 +30,6 @@
|
|||||||
import { Value } from "typebox/value";
|
import { Value } from "typebox/value";
|
||||||
import type { TSchema } from "typebox";
|
import type { TSchema } from "typebox";
|
||||||
import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
|
import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
|
||||||
import { repairMathForDisplay } from "./math-delims.ts";
|
|
||||||
import { healEncodedArguments } from "./argument-heal.ts";
|
import { healEncodedArguments } from "./argument-heal.ts";
|
||||||
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
|
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
|
||||||
|
|
||||||
@@ -67,28 +66,9 @@ function boundaryParse<T>(schema: TSchema, input: unknown): T | undefined {
|
|||||||
* never have been posed (e.g. `correctAnswer: undefined`, which Convert
|
* never have been posed (e.g. `correctAnswer: undefined`, which Convert
|
||||||
* widens to "") renders as a quiet fallback, not a broken quiz.
|
* widens to "") renders as a quiet fallback, not a broken quiz.
|
||||||
*/
|
*/
|
||||||
/** LaTeX-escape restoration, deep over every string field (the JSON-debris
|
/** Shape parsing never repairs authored text. The dock builds its answer
|
||||||
* fix, owner ruling 2026-09-26): records persist RAW call args, so
|
* data from this source copy; recovery belongs exclusively to presentation
|
||||||
* single-backslash LaTeX eaten by JSON escape resolution (FF ate the 'f' of
|
* (ToolContentResponse), never option identities, grading, or snapshots. */
|
||||||
* \forall, VT the 'v' of \varphi, TAB the 't' of \to) survives to the
|
|
||||||
* display path. This is the ONE seam every rendered question passes
|
|
||||||
* through — new turns and already-poisoned records alike heal here.
|
|
||||||
* healOverescapedCommands (owner ruling 2026-10-15) rides the same walk:
|
|
||||||
* the same models also stochastically DOUBLE command backslashes
|
|
||||||
* (`\\nabla` saved where `\nabla` was meant — KaTeX shows the row break
|
|
||||||
* plus "nabla" as letters), so even runs before a command start inside
|
|
||||||
* balanced math spans collapse to one backslash. */
|
|
||||||
function restoreMathEscapesDeep(value: unknown): unknown {
|
|
||||||
if (typeof value === "string") return repairMathForDisplay(value);
|
|
||||||
if (Array.isArray(value)) return value.map(restoreMathEscapesDeep);
|
|
||||||
if (typeof value === "object" && value !== null) {
|
|
||||||
const out: Record<string, unknown> = {};
|
|
||||||
for (const [k, v] of Object.entries(value)) out[k] = restoreMathEscapesDeep(v);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseQuizInput(input: unknown): QuizToolInput | undefined {
|
export function parseQuizInput(input: unknown): QuizToolInput | undefined {
|
||||||
// quizDisplayInputSchema (question-core, owner 2026-10-03): base + the
|
// quizDisplayInputSchema (question-core, owner 2026-10-03): base + the
|
||||||
// OPTIONAL `type` — new teach-phase records carry predict|check and the
|
// OPTIONAL `type` — new teach-phase records carry predict|check and the
|
||||||
@@ -100,7 +80,7 @@ export function parseQuizInput(input: unknown): QuizToolInput | undefined {
|
|||||||
// wobble must heal HERE too or a posed quiz renders invisible (the
|
// wobble must heal HERE too or a posed quiz renders invisible (the
|
||||||
// tool-parts law). healEncodedArguments is the same pure pass the
|
// tool-parts law). healEncodedArguments is the same pure pass the
|
||||||
// boundary's withArgumentHeal runs.
|
// boundary's withArgumentHeal runs.
|
||||||
const healedInput = healEncodedArguments(restoreMathEscapesDeep(input), quizDisplayInputSchema);
|
const healedInput = healEncodedArguments(input, quizDisplayInputSchema);
|
||||||
const cleaned = boundaryParse<QuizToolInput>(quizDisplayInputSchema, healedInput);
|
const cleaned = boundaryParse<QuizToolInput>(quizDisplayInputSchema, healedInput);
|
||||||
if (!cleaned) return undefined;
|
if (!cleaned) return undefined;
|
||||||
if (quizInputProblems(cleaned).length > 0) return undefined;
|
if (quizInputProblems(cleaned).length > 0) return undefined;
|
||||||
@@ -114,7 +94,7 @@ export function parseQuizInput(input: unknown): QuizToolInput | undefined {
|
|||||||
* keying, so they parse to undefined exactly like the zod seam did).
|
* keying, so they parse to undefined exactly like the zod seam did).
|
||||||
*/
|
*/
|
||||||
export function parseAskInput(input: unknown): AskToolInput | undefined {
|
export function parseAskInput(input: unknown): AskToolInput | undefined {
|
||||||
const cleaned = boundaryParse<AskToolInput>(askInputSchema, restoreMathEscapesDeep(input));
|
const cleaned = boundaryParse<AskToolInput>(askInputSchema, input);
|
||||||
if (!cleaned) return undefined;
|
if (!cleaned) return undefined;
|
||||||
if (askOptionProblems(cleaned.options ?? []).length > 0) return undefined;
|
if (askOptionProblems(cleaned.options ?? []).length > 0) return undefined;
|
||||||
return cleaned;
|
return cleaned;
|
||||||
@@ -127,7 +107,7 @@ export function parseAskInput(input: unknown): AskToolInput | undefined {
|
|||||||
* variants share the same fields; the teacher's nodeId is an unknown
|
* variants share the same fields; the teacher's nodeId is an unknown
|
||||||
* key Value.Clean strips, same as the quiz seam. */
|
* key Value.Clean strips, same as the quiz seam. */
|
||||||
export function parseExplainInput(input: unknown): ExplainToolInput | undefined {
|
export function parseExplainInput(input: unknown): ExplainToolInput | undefined {
|
||||||
const cleaned = boundaryParse<ExplainToolInput>(explainDisplayInputSchema, restoreMathEscapesDeep(input));
|
const cleaned = boundaryParse<ExplainToolInput>(explainDisplayInputSchema, input);
|
||||||
if (!cleaned) return undefined;
|
if (!cleaned) return undefined;
|
||||||
if (explainInputProblems(cleaned).length > 0) return undefined;
|
if (explainInputProblems(cleaned).length > 0) return undefined;
|
||||||
return cleaned;
|
return cleaned;
|
||||||
|
|||||||
+27
-1
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
import type { LhMarkerMessage, LhMessage } from "./native-messages.ts";
|
import type { LhMarkerMessage, LhMessage } from "./native-messages.ts";
|
||||||
import { modelSolutionForConclude, PRACTICE_CONCLUDE_TOOL, problemInputForConclude } from "./native-messages.ts";
|
import { modelSolutionForConclude, PRACTICE_CONCLUDE_TOOL, problemInputForConclude, SEND_TO_USER_TOOL, sendToUserText } from "./native-messages.ts";
|
||||||
import { activeCompactionOf, type CompactionDetails } from "./compaction.ts";
|
import { activeCompactionOf, type CompactionDetails } from "./compaction.ts";
|
||||||
import { QUESTION_TOOLS } from "./pending.ts";
|
import { QUESTION_TOOLS } from "./pending.ts";
|
||||||
import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts";
|
import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts";
|
||||||
@@ -124,6 +124,14 @@ function lastSuccessfulPlanInit(parts: LhViewPart[]): number {
|
|||||||
* view messages. Pure: the same input always projects to the same list. */
|
* view messages. Pure: the same input always projects to the same list. */
|
||||||
export function projectView(messages: readonly LhMessage[], live?: LiveTurnState): LhViewMessage[] {
|
export function projectView(messages: readonly LhMessage[], live?: LiveTurnState): LhViewMessage[] {
|
||||||
const out: LhViewMessage[] = [];
|
const out: LhViewMessage[] = [];
|
||||||
|
// Output calls become text rather than tool parts, so read settled
|
||||||
|
// failures up front: a failed call must retain the normal error path.
|
||||||
|
const outputErrors = new Map<string, ToolResultMessage>();
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.role === "toolResult" && message.toolName === SEND_TO_USER_TOOL && message.isError) {
|
||||||
|
outputErrors.set(message.toolCallId, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const lastAssistant = (): LhViewMessage | undefined => {
|
const lastAssistant = (): LhViewMessage | undefined => {
|
||||||
const last = out[out.length - 1];
|
const last = out[out.length - 1];
|
||||||
@@ -218,6 +226,24 @@ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState
|
|||||||
reasoning.push({ type: "reasoning", id: `${m.id}-r${reasoningN++}`, text: block.thinking, state: "done" });
|
reasoning.push({ type: "reasoning", id: `${m.id}-r${reasoningN++}`, text: block.thinking, state: "done" });
|
||||||
} else if (block.type === "toolCall") {
|
} else if (block.type === "toolCall") {
|
||||||
const part = toolPartOf(block, live, streamingMessage);
|
const part = toolPartOf(block, live, streamingMessage);
|
||||||
|
if (block.name === SEND_TO_USER_TOOL) {
|
||||||
|
// Failed/aborted generations never execute their calls;
|
||||||
|
// the provider error/abort UI already owns that outcome.
|
||||||
|
if (assistant.stopReason === "error" || assistant.stopReason === "aborted") continue;
|
||||||
|
const error = outputErrors.get(block.id);
|
||||||
|
if (error) applyResult(part, error);
|
||||||
|
if (part.state === "output-error") {
|
||||||
|
rest.push(part);
|
||||||
|
} else {
|
||||||
|
const text = sendToUserText(block);
|
||||||
|
if (text !== undefined) {
|
||||||
|
rest.push({ type: "text", id: `${m.id}-t${textN++}`, text, state: "done" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No live tool card or acknowledgement: this channel is
|
||||||
|
// ordinary prose, including while its arguments stream.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// The conclusion's reveal join (owner round, 2026-10-05): the
|
// The conclusion's reveal join (owner round, 2026-10-05): the
|
||||||
// hero of a conclude card is the modelSolution committed at POSE
|
// hero of a conclude card is the modelSolution committed at POSE
|
||||||
// time — a record fold, enriched here so the card renders pure.
|
// time — a record fold, enriched here so the card renders pure.
|
||||||
|
|||||||
+48
-15
@@ -17,7 +17,7 @@ import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
|||||||
import type { ModelRole } from "../../config.ts";
|
import type { ModelRole } from "../../config.ts";
|
||||||
import {
|
import {
|
||||||
deliverJobResult,
|
deliverJobResult,
|
||||||
noteSteerVisible,
|
noteSteersDurable,
|
||||||
reconcileUserSteers,
|
reconcileUserSteers,
|
||||||
registerLiveAgent,
|
registerLiveAgent,
|
||||||
restoreUncommittedAutomatic,
|
restoreUncommittedAutomatic,
|
||||||
@@ -42,8 +42,8 @@ import {
|
|||||||
import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope, type ProviderKeyMissing } from "../core/settings.ts";
|
import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope, type ProviderKeyMissing } from "../core/settings.ts";
|
||||||
import { beginTurn, endTurn } from "./turn-liveness.ts";
|
import { beginTurn, endTurn } from "./turn-liveness.ts";
|
||||||
import { applyParkedDismissal } from "./dismissal-park.ts";
|
import { applyParkedDismissal } from "./dismissal-park.ts";
|
||||||
import { registerTurn, unregisterTurn } from "./turn-runs.ts";
|
import { registerTurn, rememberTurnFailure, unregisterTurn } from "./turn-runs.ts";
|
||||||
import { persistTurn, triggerExtraction } from "./turn-service.ts";
|
import { persistTurn, triggerExtraction, TurnPersistenceError } from "./turn-service.ts";
|
||||||
import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts";
|
import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts";
|
||||||
import { composeInstructions } from "../agent/teacher-agent.ts";
|
import { composeInstructions } from "../agent/teacher-agent.ts";
|
||||||
import { ensureCompacted, kickCompaction, effectiveThresholdTokens } from "./compaction.ts";
|
import { ensureCompacted, kickCompaction, effectiveThresholdTokens } from "./compaction.ts";
|
||||||
@@ -58,7 +58,7 @@ import {
|
|||||||
type TurnInput,
|
type TurnInput,
|
||||||
} from "../core/input-events.ts";
|
} from "../core/input-events.ts";
|
||||||
import { steerDelivered } from "../core/contracts.ts";
|
import { steerDelivered } from "../core/contracts.ts";
|
||||||
import type {CallUsage, SteerMessage} from "../core/contracts.ts";
|
import type {CallUsage} from "../core/contracts.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One chat turn over the server-owned native record. Framework-free (plain
|
* One chat turn over the server-owned native record. Framework-free (plain
|
||||||
@@ -79,6 +79,10 @@ import type {CallUsage, SteerMessage} from "../core/contracts.ts";
|
|||||||
* for write-ahead persistence, turn liveness, and the turn services.
|
* for write-ahead persistence, turn liveness, and the turn services.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
function settlementErrorText(error: unknown): string {
|
||||||
|
return error instanceof TurnPersistenceError ? error.message : "An error occurred.";
|
||||||
|
}
|
||||||
|
|
||||||
const jobRunner = createJobRunner({
|
const jobRunner = createJobRunner({
|
||||||
chatStore: stores.chatStore,
|
chatStore: stores.chatStore,
|
||||||
jobStore: stores.jobStore,
|
jobStore: stores.jobStore,
|
||||||
@@ -255,7 +259,8 @@ export async function handleChatInput(
|
|||||||
chatId,
|
chatId,
|
||||||
);
|
);
|
||||||
const compactedRecord = stores.chatStore.getChat(chatId);
|
const compactedRecord = stores.chatStore.getChat(chatId);
|
||||||
if (compactedRecord) record = compactedRecord;
|
if (!compactedRecord) return Response.json({ error: "Unknown chat" }, { status: 404 });
|
||||||
|
record = compactedRecord;
|
||||||
|
|
||||||
// The input's native appends (pure; the honest 400 for an answer with no
|
// The input's native appends (pure; the honest 400 for an answer with no
|
||||||
// pending question call). Computed BEFORE beginTurn so a refused input
|
// pending question call). Computed BEFORE beginTurn so a refused input
|
||||||
@@ -289,7 +294,11 @@ export async function handleChatInput(
|
|||||||
// the composer gates), so adjacency holds by construction.
|
// the composer gates), so adjacency holds by construction.
|
||||||
if (input.type === "answer" && danglingQuestionOf([...record.messages, ...appends]) !== null) {
|
if (input.type === "answer" && danglingQuestionOf([...record.messages, ...appends]) !== null) {
|
||||||
const merged = [...record.messages, ...appends];
|
const merged = [...record.messages, ...appends];
|
||||||
stores.chatStore.saveMessages(chatId, merged);
|
try { stores.chatStore.saveMessages(chatId, merged); }
|
||||||
|
catch (err) {
|
||||||
|
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
|
||||||
|
return new Response("Your input could not be saved. Please try again.", { status: 500 });
|
||||||
|
}
|
||||||
emit("record-changed", { chatId });
|
emit("record-changed", { chatId });
|
||||||
return settledTurnResponse(); // the cheap terminal — no beginTurn, no turn
|
return settledTurnResponse(); // the cheap terminal — no beginTurn, no turn
|
||||||
}
|
}
|
||||||
@@ -313,14 +322,17 @@ export async function handleChatInput(
|
|||||||
let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length;
|
let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length;
|
||||||
try {
|
try {
|
||||||
if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged);
|
if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged);
|
||||||
// Dock fix (owner ruling 2026-09-26): persisted markers are visible
|
|
||||||
// in the record — flip their jobs now (the wake's steers ride this
|
|
||||||
// write-ahead; the delivering turn may run for minutes).
|
|
||||||
for (const appended of stagedAutomatic ? [] : appends) {
|
|
||||||
if ((appended as { role?: string }).role === "custom") noteSteerVisible(appended as SteerMessage);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
|
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
|
||||||
|
endTurn(chatId);
|
||||||
|
return new Response("Your input could not be saved. Please try again.", { status: 500 });
|
||||||
|
}
|
||||||
|
// Visibility bookkeeping is not the canonical save. Its failure must
|
||||||
|
// neither misreport a saved input as lost nor prevent the turn running.
|
||||||
|
try {
|
||||||
|
if (!stagedAutomatic) noteSteersDurable(chatId, merged);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[chat ${chatId}] input visibility bookkeeping failed:`, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// MONEY CHANNEL (wave 4): one collector per request, fed by the agent's
|
// MONEY CHANNEL (wave 4): one collector per request, fed by the agent's
|
||||||
@@ -370,7 +382,11 @@ export async function handleChatInput(
|
|||||||
foregroundOwed: !callPurpose.background,
|
foregroundOwed: !callPurpose.background,
|
||||||
purpose: callPurpose,
|
purpose: callPurpose,
|
||||||
scopeCurrent: (scope) => connectionScopeCurrent(conversation.provider, scope),
|
scopeCurrent: (scope) => connectionScopeCurrent(conversation.provider, scope),
|
||||||
commitStaged: () => { stores.chatStore.saveMessages(chatId, merged); persistBaseCount = merged.length; },
|
commitStaged: () => {
|
||||||
|
stores.chatStore.saveMessages(chatId, merged);
|
||||||
|
persistBaseCount = merged.length;
|
||||||
|
noteSteersDurable(chatId, merged);
|
||||||
|
},
|
||||||
restoreAutomatic: (automaticIds, retainedIds) => restoreUncommittedAutomatic(chatId, constructed, automaticIds, retainedIds),
|
restoreAutomatic: (automaticIds, retainedIds) => restoreUncommittedAutomatic(chatId, constructed, automaticIds, retainedIds),
|
||||||
});
|
});
|
||||||
const agentOpts: AgentBuildOptions = {
|
const agentOpts: AgentBuildOptions = {
|
||||||
@@ -468,7 +484,8 @@ export async function handleChatInput(
|
|||||||
// run's first LLM call (the native replacement for continuation
|
// run's first LLM call (the native replacement for continuation
|
||||||
// coalescing). Settlement still cannot beat the flow below: it needs
|
// coalescing). Settlement still cannot beat the flow below: it needs
|
||||||
// a streamed response, which never happens inside the constructor.
|
// a streamed response, which never happens inside the constructor.
|
||||||
registerLiveAgent(chatId, constructed);
|
registerLiveAgent(chatId, constructed, input.type !== "steers" && input.type !== "continue",
|
||||||
|
input.type === "steers" ? new Set(appends.map((message) => message.id)) : undefined);
|
||||||
// Debug recorder run marker (owner, 2026-10-04): correlates the
|
// Debug recorder run marker (owner, 2026-10-04): correlates the
|
||||||
// JSONL event stream with the record's turns; no-op unless
|
// JSONL event stream with the record's turns; no-op unless
|
||||||
// LH_PROVIDER_EVENTS=1.
|
// LH_PROVIDER_EVENTS=1.
|
||||||
@@ -496,10 +513,26 @@ export async function handleChatInput(
|
|||||||
console.error(`[chat ${chatId}] turn error:`, error);
|
console.error(`[chat ${chatId}] turn error:`, error);
|
||||||
return "An error occurred.";
|
return "An error occurred.";
|
||||||
},
|
},
|
||||||
|
onSettleError: settlementErrorText,
|
||||||
|
onSettleFailure: (error, reportedErrorText) => {
|
||||||
|
// POST only acknowledges acceptance; the submitter may attach
|
||||||
|
// after this turn has already failed and left the registry.
|
||||||
|
rememberTurnFailure(chatId, reportedErrorText ?? settlementErrorText(error));
|
||||||
|
endTurn(chatId);
|
||||||
|
unregisterTurn(chatId);
|
||||||
|
unregisterLiveAgent(chatId);
|
||||||
|
// The canonical save may have succeeded before later bookkeeping
|
||||||
|
// threw. Reconcile against actual durability, never replay input
|
||||||
|
// merely because usage/extraction/other settlement work failed.
|
||||||
|
const persisted = stores.chatStore.getChat(chatId)?.messages ?? [];
|
||||||
|
reconcileUserSteers(chatId, persisted);
|
||||||
|
settleSteers(chatId, persisted, true, constructed);
|
||||||
|
},
|
||||||
onFinish: (turnMessages, isAborted) => {
|
onFinish: (turnMessages, isAborted) => {
|
||||||
// Wave B: the record write is the agent's own final state —
|
// Wave B: the record write is the agent's own final state —
|
||||||
// the new messages are the slice past the merged base.
|
// the new messages are the slice past the merged base.
|
||||||
const stamped = persistTurn(stores.chatStore, chatId, persistBaseCount, turnMessages, isAborted);
|
const stamped = persistTurn(stores.chatStore, chatId, persistBaseCount, turnMessages, isAborted);
|
||||||
|
noteSteersDurable(chatId, stores.chatStore.getChat(chatId)?.messages ?? stamped);
|
||||||
// usage log append (idempotent per turn id) — the stats bar
|
// usage log append (idempotent per turn id) — the stats bar
|
||||||
// refolds this on the client after the turn settles. The
|
// refolds this on the client after the turn settles. The
|
||||||
// provenance message is the turn's FIRST new assistant message.
|
// provenance message is the turn's FIRST new assistant message.
|
||||||
@@ -545,7 +578,7 @@ export async function handleChatInput(
|
|||||||
// resolve; stranded markers return to pending), then let
|
// resolve; stranded markers return to pending), then let
|
||||||
// the pump take any stragglers down the idle wake.
|
// the pump take any stragglers down the idle wake.
|
||||||
unregisterLiveAgent(chatId);
|
unregisterLiveAgent(chatId);
|
||||||
settleSteers(chatId, stamped);
|
settleSteers(chatId, stamped, false, constructed);
|
||||||
// Composer steers (2026-10-03): delivered ids dropped, stranded
|
// Composer steers (2026-10-03): delivered ids dropped, stranded
|
||||||
// ones stay parked for the chat's next run (the registry is the
|
// ones stay parked for the chat's next run (the registry is the
|
||||||
// stranding safety net — the agent's undrained copy died above).
|
// stranding safety net — the agent's undrained copy died above).
|
||||||
|
|||||||
+27
-6
@@ -1,10 +1,13 @@
|
|||||||
import { abortExtraction, extractionStatuses } from "../agent/subagents/extraction.ts";
|
import { abortExtraction, extractionStatuses } from "../agent/subagents/extraction.ts";
|
||||||
|
import { isJobLive } from "../agent/subagents/aborts.ts";
|
||||||
import { coverageOf } from "../core/coverage.ts";
|
import { coverageOf } from "../core/coverage.ts";
|
||||||
import { deleteChatTraces, type ChatDeleteMode, type ChatDeletionStats } from "../core/graph-deletion.ts";
|
import { deleteChatTraces, type ChatDeleteMode, type ChatDeletionStats } from "../core/graph-deletion.ts";
|
||||||
import { titleFromPrompt } from "../core/contracts.ts";
|
import { titleFromPrompt } from "../core/contracts.ts";
|
||||||
import { graphRevision } from "./graph.ts";
|
import { graphRevision } from "./graph.ts";
|
||||||
import { stores } from "./stores.ts";
|
import { stores } from "./stores.ts";
|
||||||
import { isTurnRunning } from "./turn-liveness.ts";
|
import { isTurnRunning } from "./turn-liveness.ts";
|
||||||
|
import { clearTurnFailure } from "./turn-runs.ts";
|
||||||
|
import { discardChatDeliveries } from "./jobs.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side chat entries for the transport layer (app/api/chats*,
|
* Server-side chat entries for the transport layer (app/api/chats*,
|
||||||
@@ -65,10 +68,7 @@ export type ChatDeleteOutcome =
|
|||||||
* the wind-down finds the chat gone at its merge and aborts with
|
* the wind-down finds the chat gone at its merge and aborts with
|
||||||
* nothing merged.
|
* nothing merged.
|
||||||
*/
|
*/
|
||||||
export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): Promise<ChatDeleteOutcome> {
|
function runningWorkBlocker(chatId: string): Extract<ChatDeleteOutcome, { status: 409 }> | null {
|
||||||
if (!stores.chatStore.getChat(chatId)) {
|
|
||||||
return { ok: false, status: 404, code: "not_found", error: "No such session" };
|
|
||||||
}
|
|
||||||
if (isTurnRunning(chatId)) {
|
if (isTurnRunning(chatId)) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -78,7 +78,11 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P
|
|||||||
jobIds: [],
|
jobIds: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const runningJobs = stores.jobStore.listJobs(chatId).filter((job) => job.status === "running");
|
// Legacy records kept completed workers running until delivery. A saved
|
||||||
|
// result without a live loop is pending session data, not computing work;
|
||||||
|
// deletion must not depend on a prior recovery poll to recognize it.
|
||||||
|
const runningJobs = stores.jobStore.listJobs(chatId).filter((job) =>
|
||||||
|
job.status === "running" && (!job.result || isJobLive(job.id)));
|
||||||
if (runningJobs.length > 0) {
|
if (runningJobs.length > 0) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -88,6 +92,15 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P
|
|||||||
jobIds: runningJobs.map((job) => job.id),
|
jobIds: runningJobs.map((job) => job.id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): Promise<ChatDeleteOutcome> {
|
||||||
|
if (!stores.chatStore.getChat(chatId)) {
|
||||||
|
return { ok: false, status: 404, code: "not_found", error: "No such session" };
|
||||||
|
}
|
||||||
|
const blocker = runningWorkBlocker(chatId);
|
||||||
|
if (blocker) return blocker;
|
||||||
if (extractionStatuses().some((run) => run.chatId === chatId)) {
|
if (extractionStatuses().some((run) => run.chatId === chatId)) {
|
||||||
abortExtraction(chatId); // true cancellation — spend stops, nothing merges
|
abortExtraction(chatId); // true cancellation — spend stops, nothing merges
|
||||||
const deadline = Date.now() + WIND_DOWN_TIMEOUT_MS;
|
const deadline = Date.now() + WIND_DOWN_TIMEOUT_MS;
|
||||||
@@ -104,11 +117,19 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A result wake or a worker may have started during extraction wind-down.
|
||||||
|
// Recheck immediately before the synchronous purge, with no await gap.
|
||||||
|
const lateBlocker = runningWorkBlocker(chatId);
|
||||||
|
if (lateBlocker) return lateBlocker;
|
||||||
|
|
||||||
// THE SYNC BLOCK — no awaits below (see the doc comment). Order:
|
// THE SYNC BLOCK — no awaits below (see the doc comment). Order:
|
||||||
// ledgers → jobs → the chat record.
|
// ledgers → jobs → the chat record.
|
||||||
const stats = deleteChatTraces(stores.graphStore, chatId, mode);
|
const stats = deleteChatTraces(stores.graphStore, chatId, mode);
|
||||||
const jobsRemoved = stores.jobStore.deleteJobsForChat(chatId);
|
const jobsRemoved = stores.jobStore.deleteJobsForChat(chatId);
|
||||||
stores.chatStore.deleteChat(chatId);
|
if (stores.chatStore.deleteChat(chatId)) {
|
||||||
|
discardChatDeliveries(chatId);
|
||||||
|
clearTurnFailure(chatId);
|
||||||
|
}
|
||||||
return { ok: true, mode, stats, jobsRemoved, graphRevision: graphRevision() };
|
return { ok: true, mode, stats, jobsRemoved, graphRevision: graphRevision() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s
|
|||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
};
|
};
|
||||||
const { text } = await runSubagent([], {
|
const { text, newMessages } = await runSubagent([], {
|
||||||
// The engine override is always present (the chat's own conversation
|
// The engine override is always present (the chat's own conversation
|
||||||
// engine — owner ruling), so the role label never resolves an
|
// engine — owner ruling), so the role label never resolves an
|
||||||
// engine; "researcher" is the least-wrong existing ModelRole label
|
// engine; "researcher" is the least-wrong existing ModelRole label
|
||||||
@@ -83,6 +83,12 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s
|
|||||||
usage = u;
|
usage = u;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// A provider-declared incomplete response is not a usable checkpoint,
|
||||||
|
// even when it contains nonempty text. Match Pi's compaction failure
|
||||||
|
// semantics: leave the existing history and prior summary untouched.
|
||||||
|
if (newMessages.some((message) => message.role === "assistant" && message.stopReason === "length")) {
|
||||||
|
throw new Error("Compaction summary hit the output limit and is incomplete");
|
||||||
|
}
|
||||||
return { summary: text, usage };
|
return { summary: text, usage };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +207,14 @@ async function compact(deps: CompactionDeps, chatId: string): Promise<void> {
|
|||||||
spanStart = prevKept;
|
spanStart = prevKept;
|
||||||
priorSummary = previous.content;
|
priorSummary = previous.content;
|
||||||
}
|
}
|
||||||
const spanText = serializeForSummary(messages.slice(spanStart, cut));
|
const span = messages.slice(spanStart, cut);
|
||||||
|
// The separately supplied prior summary replaces all summary markers
|
||||||
|
// in this span. Without a resolved prior boundary, retain the existing
|
||||||
|
// legacy fallback rather than silently discarding summary-only context.
|
||||||
|
const summaryMessages = priorSummary
|
||||||
|
? span.filter((m) => m.role !== "custom" || m.customType !== "compaction")
|
||||||
|
: span;
|
||||||
|
const spanText = serializeForSummary(summaryMessages);
|
||||||
const fullSpan = priorSummary ? `[SYSTEM NOTE — prior compaction summary, still authoritative where not superseded]\n${priorSummary}\n\n${spanText}` : spanText;
|
const fullSpan = priorSummary ? `[SYSTEM NOTE — prior compaction summary, still authoritative where not superseded]\n${priorSummary}\n\n${spanText}` : spanText;
|
||||||
|
|
||||||
const prompt = buildCompactionPrompt({
|
const prompt = buildCompactionPrompt({
|
||||||
|
|||||||
@@ -43,4 +43,5 @@ export * from "./compaction.ts";
|
|||||||
export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts";
|
export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts";
|
||||||
export * from "./observability.ts";
|
export * from "./observability.ts";
|
||||||
export * from "./model-catalog.ts";
|
export * from "./model-catalog.ts";
|
||||||
|
export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts";
|
||||||
export { sessionCost } from "./pricing.ts";
|
export { sessionCost } from "./pricing.ts";
|
||||||
|
|||||||
+185
-163
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* THE WAKE PIPELINE (contracts.ts "THE ASYNC PROTOCOL" §4) — server-side
|
* THE WAKE PIPELINE (contracts.ts "THE ASYNC PROTOCOL" §4) — server-side
|
||||||
* steer delivery for finished sub-agent jobs. Wave C (2026-09-26):
|
* steer delivery for finished sub-agent jobs. Wave C (2026-09-26):
|
||||||
* delivery rides pi's OWN follow-up queue when a run is live, and the
|
* delivery rides pi's OWN steering queue when a run is live, and the
|
||||||
* idle wake otherwise. One registry, two vehicles.
|
* idle wake otherwise. One registry, two vehicles.
|
||||||
*
|
*
|
||||||
* deliverJobResult(steer) — the runner calls this on job completion.
|
* deliverJobResult(steer) — the runner calls this on job completion.
|
||||||
@@ -40,15 +40,17 @@
|
|||||||
*
|
*
|
||||||
* settleSteers(chatId, persisted) — the handler calls this at turn
|
* settleSteers(chatId, persisted) — the handler calls this at turn
|
||||||
* settlement (after unregisterLiveAgent): entries the run DELIVERED
|
* settlement (after unregisterLiveAgent): entries the run DELIVERED
|
||||||
* resolve + flip their jobs; entries that were handed to the dying
|
* resolve + acknowledge delivery; entries handed to the dying
|
||||||
* run but never injected (stopper exit, abort, a missed final poll)
|
* run but never injected (stopper exit, abort, a missed final poll)
|
||||||
* return to pending and the pump restarts — no stranding, no loss.
|
* return to pending and the pump restarts — no stranding, no loss.
|
||||||
*
|
*
|
||||||
* Crash recovery (recoverStaleJobs): a process restart can orphan jobs —
|
* Worker status ends at computation completion; JobRecord.delivery owns
|
||||||
* "running" with a result (the wake never fired) or without one (the
|
* the pending result or abort notice independently. Registry entries
|
||||||
* sub-agent loop itself died). The sweep re-delivers finished results
|
* survive in-flight wakes, so duplicate submissions share one promise.
|
||||||
* (idempotently) and re-spawns unfinished loops, both via injected
|
* Failed starts stay pending without timers or worker errors. Recovery
|
||||||
* seams. It runs lazily off the /api/jobs poll, throttled.
|
* restores known pending deliveries and re-spawns unfinished workers;
|
||||||
|
* registration also restores pending delivery before the first poll.
|
||||||
|
* Legacy terminal jobs without delivery metadata are never replayed.
|
||||||
*
|
*
|
||||||
* All mutable state lives on globalThis (the turn-liveness lesson: Next dev
|
* All mutable state lives on globalThis (the turn-liveness lesson: Next dev
|
||||||
* gives each route entry its own module instances — a module-level Map would
|
* gives each route entry its own module instances — a module-level Map would
|
||||||
@@ -83,6 +85,14 @@ export type JobRespawn = (jobId: string) => void;
|
|||||||
|
|
||||||
interface QueuedSteer {
|
interface QueuedSteer {
|
||||||
steer: SteerMessage;
|
steer: SteerMessage;
|
||||||
|
promise: Promise<void>;
|
||||||
|
/** Wake cargo remains owned here, but must not steer into its own run. */
|
||||||
|
waking?: boolean;
|
||||||
|
/** The constructed vehicle, once startup claims a turn. Until then only
|
||||||
|
* the pump owns waking: an unrelated foreground settlement cannot free it. */
|
||||||
|
wakeAgent?: Agent;
|
||||||
|
/** A failed start waits for a learner run or process restart, not a timer. */
|
||||||
|
deferred?: boolean;
|
||||||
resolve: () => void;
|
resolve: () => void;
|
||||||
reject: (reason: Error) => void;
|
reject: (reason: Error) => void;
|
||||||
/** The live agent this marker was steered into (agent.steer) — set by
|
/** The live agent this marker was steered into (agent.steer) — set by
|
||||||
@@ -166,18 +176,37 @@ function jobIdOf(steer: SteerMessage): string | null {
|
|||||||
return typeof details?.jobId === "string" ? details.jobId : null;
|
return typeof details?.jobId === "string" ? details.jobId : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dock bookkeeping: the marker reached the learner's view — flip its job
|
/** Call only after canonical conversation persistence. Streaming visibility
|
||||||
* NOW instead of waiting for the delivering turn to settle. Called from
|
* is not durable acceptance. The saved marker wins over delivery-bookkeeping
|
||||||
* BOTH visibility seams: the marker's message_end wire event (live steer
|
* failures; acknowledging delivery never changes the worker's outcome. */
|
||||||
* injection — the card is on screen while the teacher keeps teaching) and
|
export function noteSteersDurable(chatId: string, persisted: readonly LhMessage[]): void {
|
||||||
* the write-ahead persist in handleChatInput (the wake's input appends —
|
for (const message of persisted) {
|
||||||
* the card is durable in the record). The registry's delivery promise
|
if (message.role === "custom") finalizeSteer(message);
|
||||||
* still settles via settleSteers / the runner-200 path; finalizeJob is
|
}
|
||||||
* idempotent, so the later flip is a no-op. Owner ruling (2026-09-26): a
|
for (const entry of [...(state().queues.get(chatId) ?? [])]) {
|
||||||
* delivered subagent leaves the background dock the moment its result is
|
if (persisted.some((message) => message.id === entry.steer.id)) completeEntry(chatId, entry);
|
||||||
* visible, not when the teacher's turn ends. */
|
}
|
||||||
export function noteSteerVisible(steer: SteerMessage): void {
|
}
|
||||||
finalizeSteer(steer);
|
|
||||||
|
function removeEntry(chatId: string, entry: QueuedSteer): void {
|
||||||
|
const remaining = (state().queues.get(chatId) ?? []).filter((candidate) => candidate !== entry);
|
||||||
|
if (remaining.length > 0) state().queues.set(chatId, remaining);
|
||||||
|
else state().queues.delete(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeEntry(chatId: string, entry: QueuedSteer): void {
|
||||||
|
finalizeSteer(entry.steer);
|
||||||
|
removeEntry(chatId, entry);
|
||||||
|
entry.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletion discards the session's obligations, never wakes a deleted chat. */
|
||||||
|
export function discardChatDeliveries(chatId: string): void {
|
||||||
|
const s = state();
|
||||||
|
const queue = s.queues.get(chatId) ?? [];
|
||||||
|
s.queues.delete(chatId);
|
||||||
|
s.userSteers.delete(chatId);
|
||||||
|
for (const entry of queue) entry.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Finalize a delivered steer's job bookkeeping — job steers only. */
|
/** Finalize a delivered steer's job bookkeeping — job steers only. */
|
||||||
@@ -186,24 +215,30 @@ function finalizeSteer(steer: SteerMessage): void {
|
|||||||
if (jobId) finalizeJob(jobId);
|
if (jobId) finalizeJob(jobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fail a steer's job on unrecoverable delivery failure — job steers only. */
|
/** Queue one logical delivery per ID, including in-flight wake cargo.
|
||||||
function failSteer(steer: SteerMessage, err: Error): void {
|
* Job promises resolve on durable marker acceptance (or session deletion);
|
||||||
const jobId = jobIdOf(steer);
|
* failed starts stay pending. Non-job probe handoffs keep their existing
|
||||||
if (jobId) failJob(jobId, err);
|
* wake-acceptance/error semantics. Workers never await this promise. */
|
||||||
|
export function deliverJobResult(steer: SteerMessage): Promise<void> {
|
||||||
|
const chatId = chatIdOf(steer);
|
||||||
|
const entry = queueSteer(chatId, steer);
|
||||||
|
void pump(chatId);
|
||||||
|
return entry.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Queue ANY steer (result, abort, probe handoff) through the pipeline.
|
function queueSteer(chatId: string, steer: SteerMessage): QueuedSteer {
|
||||||
* Resolves when the wake turn has completed; rejects when delivery fails
|
const queue = state().queues.get(chatId) ?? [];
|
||||||
* unrecoverably. Never rejects for queueing itself. */
|
const existing = queue.find((entry) => entry.steer.id === steer.id);
|
||||||
export function deliverJobResult(steer: SteerMessage): Promise<void> {
|
if (existing) return existing;
|
||||||
return new Promise<void>((resolve, reject) => {
|
let resolve!: () => void;
|
||||||
const s = state();
|
let reject!: (error: Error) => void;
|
||||||
const chatId = chatIdOf(steer);
|
const promise = new Promise<void>((res, rej) => { resolve = res; reject = rej; });
|
||||||
const queue = s.queues.get(chatId) ?? [];
|
// Recovery/registration may own an entry with no waiting caller.
|
||||||
queue.push({ steer, resolve, reject });
|
void promise.catch(() => undefined);
|
||||||
s.queues.set(chatId, queue);
|
const entry: QueuedSteer = { steer, promise, resolve, reject };
|
||||||
void pump(chatId);
|
queue.push(entry);
|
||||||
});
|
state().queues.set(chatId, queue);
|
||||||
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hand every eligible pending marker to a live agent's steering queue
|
/** Hand every eligible pending marker to a live agent's steering queue
|
||||||
@@ -227,7 +262,7 @@ function handOffEligible(chatId: string, agent: Agent): void {
|
|||||||
// remain deliverable, including the first poll of an answer run.
|
// remain deliverable, including the first poll of an answer run.
|
||||||
if (automaticFollowUpAllowed(chatId, agent) && queue && queue.length > 0) {
|
if (automaticFollowUpAllowed(chatId, agent) && queue && queue.length > 0) {
|
||||||
for (const entry of queue) {
|
for (const entry of queue) {
|
||||||
if (entry.handedTo !== agent && entry.steer.customType !== "probe-handoff") {
|
if (!entry.waking && !entry.deferred && entry.handedTo !== agent && entry.steer.customType !== "probe-handoff") {
|
||||||
agent.steer(entry.steer);
|
agent.steer(entry.steer);
|
||||||
entry.handedTo = agent;
|
entry.handedTo = agent;
|
||||||
}
|
}
|
||||||
@@ -254,8 +289,13 @@ function handOffEligible(chatId: string, agent: Agent): void {
|
|||||||
* markers are steered into the run right here. The settle seam cannot
|
* markers are steered into the run right here. The settle seam cannot
|
||||||
* beat the registration: settlement needs a streamed response, which
|
* beat the registration: settlement needs a streamed response, which
|
||||||
* never happens inside the constructor call. */
|
* never happens inside the constructor call. */
|
||||||
export function registerLiveAgent(chatId: string, agent: Agent): void {
|
export function registerLiveAgent(chatId: string, agent: Agent, allowDeferred = true, wakeSteerIds?: ReadonlySet<string>): void {
|
||||||
state().liveAgents.set(chatId, agent);
|
state().liveAgents.set(chatId, agent);
|
||||||
|
restorePendingDeliveries(chatId);
|
||||||
|
for (const entry of state().queues.get(chatId) ?? []) {
|
||||||
|
if (allowDeferred) entry.deferred = false;
|
||||||
|
if (entry.waking && wakeSteerIds?.has(entry.steer.id)) entry.wakeAgent = agent;
|
||||||
|
}
|
||||||
handOffEligible(chatId, agent);
|
handOffEligible(chatId, agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +366,7 @@ export function restoreUncommittedAutomatic(chatId: string, agent: Agent, automa
|
|||||||
* unregisterLiveAgent, with the run's persisted messages. Two jobs:
|
* unregisterLiveAgent, with the run's persisted messages. Two jobs:
|
||||||
*
|
*
|
||||||
* - entries whose marker the run DELIVERED (in-run follow-up injection —
|
* - entries whose marker the run DELIVERED (in-run follow-up injection —
|
||||||
* their ids are in the persisted slice) resolve and flip their jobs;
|
* their ids are in the persisted slice) resolve and acknowledge delivery;
|
||||||
* - entries handed to the dying run but never injected (the question
|
* - entries handed to the dying run but never injected (the question
|
||||||
* stopper exited before the follow-up poll; an abort; a delivery that
|
* stopper exited before the follow-up poll; an abort; a delivery that
|
||||||
* raced the final poll) return to PENDING (handedTo cleared) and the
|
* raced the final poll) return to PENDING (handedTo cleared) and the
|
||||||
@@ -335,7 +375,7 @@ export function restoreUncommittedAutomatic(chatId: string, agent: Agent, automa
|
|||||||
* This is what makes the live hand-off safe: pi owns no retry for a marker
|
* This is what makes the live hand-off safe: pi owns no retry for a marker
|
||||||
* stranded in a settled agent's queue, so the registry stays the truth and
|
* stranded in a settled agent's queue, so the registry stays the truth and
|
||||||
* the dead agent's copy is simply abandoned (GC'd with the agent). */
|
* the dead agent's copy is simply abandoned (GC'd with the agent). */
|
||||||
export function settleSteers(chatId: string, persisted: { id?: string }[]): void {
|
export function settleSteers(chatId: string, persisted: { id?: string }[], failed = false, settledAgent?: Agent): void {
|
||||||
const queue = state().queues.get(chatId);
|
const queue = state().queues.get(chatId);
|
||||||
if (!queue || queue.length === 0) return;
|
if (!queue || queue.length === 0) return;
|
||||||
const deliveredIds = new Set(persisted.map((m) => m.id).filter(Boolean));
|
const deliveredIds = new Set(persisted.map((m) => m.id).filter(Boolean));
|
||||||
@@ -345,7 +385,11 @@ export function settleSteers(chatId: string, persisted: { id?: string }[]): void
|
|||||||
finalizeSteer(entry.steer);
|
finalizeSteer(entry.steer);
|
||||||
entry.resolve();
|
entry.resolve();
|
||||||
} else {
|
} else {
|
||||||
entry.handedTo = undefined;
|
const handedHere = entry.handedTo !== undefined && (!settledAgent || entry.handedTo === settledAgent);
|
||||||
|
const wakeHere = settledAgent !== undefined && entry.wakeAgent === settledAgent;
|
||||||
|
if (handedHere) entry.handedTo = undefined;
|
||||||
|
if (wakeHere) { entry.waking = false; entry.wakeAgent = undefined; }
|
||||||
|
if (failed && (handedHere || wakeHere) && jobIdOf(entry.steer)) entry.deferred = true;
|
||||||
remaining.push(entry);
|
remaining.push(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,16 +417,10 @@ export interface AbortOutcome {
|
|||||||
* runner registered it at loop start) — the in-flight model call
|
* runner registered it at loop start) — the in-flight model call
|
||||||
* aborts, not just the next loop check. The runner unwinds silently
|
* aborts, not just the next loop check. The runner unwinds silently
|
||||||
* (its catch checks abortSignal.aborted; finishJob checks status).
|
* (its catch checks abortSignal.aborted; finishJob checks status).
|
||||||
* 2. DROP any queued result steer for this job (the delivering-result
|
* 2. REFUSE if computation already finished. Queued results belong to
|
||||||
* phase): resolve its entry without firing the wake. If the wake
|
* the session, not to an abortable background worker.
|
||||||
* turn is ALREADY in flight (the pump shifted the entry), it's too
|
* 3. SAVE error "Aborted by learner" and a pending abort delivery together.
|
||||||
* late — the result steer lands, and the cancellation steer dedupes
|
* Delivery acknowledgement never overwrites the worker's outcome.
|
||||||
* against it via steerDelivered (same jobId metadata).
|
|
||||||
* 3. FLIP the record to error "Aborted by learner" immediately — the
|
|
||||||
* poll reflects the user's action at once (the user chose
|
|
||||||
* error-with-reason over a distinct cancelled status). finalizeJob
|
|
||||||
* never overwrites a non-running status, so the flip is terminal no
|
|
||||||
* matter what the wake does.
|
|
||||||
* 4. STEER the teacher: a one-line abort note (buildAbortSteer) through
|
* 4. STEER the teacher: a one-line abort note (buildAbortSteer) through
|
||||||
* the same wake pipeline, so the teacher's promise of a result
|
* the same wake pipeline, so the teacher's promise of a result
|
||||||
* doesn't hang silently in the lesson.
|
* doesn't hang silently in the lesson.
|
||||||
@@ -394,28 +432,18 @@ export function abortJob(jobId: string): AbortOutcome {
|
|||||||
const job = stores.jobStore.getJob(jobId);
|
const job = stores.jobStore.getJob(jobId);
|
||||||
if (!job) return { ok: false, reason: "not_found" };
|
if (!job) return { ok: false, reason: "not_found" };
|
||||||
if (job.status !== "running") return { ok: false, reason: "not_running" };
|
if (job.status !== "running") return { ok: false, reason: "not_running" };
|
||||||
|
if (job.result) {
|
||||||
fireJobAbort(jobId); // no-op when the loop already returned (delivering)
|
normalizeLegacyResult(job);
|
||||||
|
return { ok: false, reason: "not_running" };
|
||||||
// Drop any queued steers for this job (their deliver() resolves unfired).
|
|
||||||
// HANDED entries (already in a live run's follow-up queue) are past
|
|
||||||
// recall — same ruling as a fired wake: the result lands, and the abort
|
|
||||||
// note below dedupes against it via steerDelivered (same jobId).
|
|
||||||
const queue = state().queues.get(job.chatId);
|
|
||||||
if (queue) {
|
|
||||||
const remaining = queue.filter(
|
|
||||||
(e) => jobIdOf(e.steer) !== jobId || e.handedTo !== undefined,
|
|
||||||
);
|
|
||||||
if (remaining.length === 0) state().queues.delete(job.chatId);
|
|
||||||
else state().queues.set(job.chatId, remaining);
|
|
||||||
for (const entry of queue) {
|
|
||||||
if (jobIdOf(entry.steer) === jobId && entry.handedTo === undefined) entry.resolve();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stores.jobStore.updateJob(jobId, { status: "error", error: "Aborted by learner", activity: "aborted" });
|
fireJobAbort(jobId);
|
||||||
|
stores.jobStore.updateJob(jobId, {
|
||||||
|
status: "error", error: "Aborted by learner", activity: "aborted",
|
||||||
|
delivery: { kind: "abort", status: "pending" },
|
||||||
|
});
|
||||||
void deliverJobResult(buildAbortSteer(job)).catch(() => {
|
void deliverJobResult(buildAbortSteer(job)).catch(() => {
|
||||||
/* deliverJobResult marks the job error on unrecoverable failure */
|
/* The saved pending abort notice remains owned by the session. */
|
||||||
});
|
});
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@@ -470,18 +498,9 @@ async function pump(chatId: string): Promise<void> {
|
|||||||
// Idempotent delivery: the transcript already carries these steers
|
// Idempotent delivery: the transcript already carries these steers
|
||||||
// (an in-run injection persisted, a wake completed, or a duplicate
|
// (an in-run injection persisted, a wake completed, or a duplicate
|
||||||
// deliver raced in). Resolve and flip without re-firing anything.
|
// deliver raced in). Resolve and flip without re-firing anything.
|
||||||
const stillPending = queue.filter((e) => !steerDelivered(record.messages, e.steer.id));
|
noteSteersDurable(chatId, record.messages);
|
||||||
for (const entry of queue) {
|
const stillPending = (s.queues.get(chatId) ?? []).filter((entry) => !entry.waking && !entry.deferred);
|
||||||
if (!stillPending.includes(entry)) {
|
if (stillPending.length === 0) { parked = true; return; }
|
||||||
finalizeSteer(entry.steer);
|
|
||||||
entry.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (stillPending.length === 0) {
|
|
||||||
s.queues.delete(chatId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
s.queues.set(chatId, stillPending);
|
|
||||||
if (!automaticFollowUpAllowed(chatId, s.liveAgents.get(chatId))) { parked = true; return; }
|
if (!automaticFollowUpAllowed(chatId, s.liveAgents.get(chatId))) { parked = true; return; }
|
||||||
|
|
||||||
// VEHICLE 1 — THE LIVE RUN: pi's steering queue. The agent of the
|
// VEHICLE 1 — THE LIVE RUN: pi's steering queue. The agent of the
|
||||||
@@ -526,28 +545,17 @@ async function pump(chatId: string): Promise<void> {
|
|||||||
// The runner is required — chat-handler sets it at init. If some
|
// The runner is required — chat-handler sets it at init. If some
|
||||||
// module instance missed the wiring, fail loudly rather than hang.
|
// module instance missed the wiring, fail loudly rather than hang.
|
||||||
if (!s.turnRunner) {
|
if (!s.turnRunner) {
|
||||||
s.queues.delete(chatId);
|
|
||||||
const err = new Error("wake pipeline: no turn runner wired (setTurnRunner)");
|
const err = new Error("wake pipeline: no turn runner wired (setTurnRunner)");
|
||||||
for (const entry of stillPending) {
|
for (const entry of stillPending) deferOrReject(chatId, entry, err);
|
||||||
failSteer(entry.steer, err);
|
parked = true;
|
||||||
entry.reject(err);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A live run may have delivered some markers while the gates were
|
// A live run may have delivered some markers while the gates were
|
||||||
// closed — re-check: only still-missing markers ride the wake.
|
// closed — re-check: only still-missing markers ride the wake.
|
||||||
const fire = stillPending.filter((e) => !steerDelivered(fresh.messages, e.steer.id));
|
noteSteersDurable(chatId, fresh.messages);
|
||||||
for (const entry of stillPending) {
|
const fire = (s.queues.get(chatId) ?? []).filter((entry) => !entry.waking && !entry.deferred && !entry.handedTo);
|
||||||
if (!fire.includes(entry)) {
|
if (fire.length === 0) return;
|
||||||
finalizeSteer(entry.steer);
|
|
||||||
entry.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (fire.length === 0) {
|
|
||||||
s.queues.delete(chatId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// THE DISMISSAL PAUSE (owner design 2026-10-03, "A2"): the record
|
// THE DISMISSAL PAUSE (owner design 2026-10-03, "A2"): the record
|
||||||
// ends in a user dismissal — the session waits for the USER. Hold
|
// ends in a user dismissal — the session waits for the USER. Hold
|
||||||
@@ -561,50 +569,37 @@ async function pump(chatId: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEQUEUE-AT-FIRE (the Wave C lesson from the Wave B regression):
|
// Keep ownership through the wake. The flag excludes cargo from
|
||||||
// the carried markers leave the registry BEFORE the turn runs —
|
// its own live-agent handoff while duplicate callers share it.
|
||||||
// the wake run's OWN follow-up poll must never see its own cargo,
|
for (const entry of fire) entry.waking = true;
|
||||||
// and a 409 (client raced in) must be able to push them back.
|
|
||||||
s.queues.delete(chatId);
|
|
||||||
const steers = fire.map((e) => e.steer);
|
const steers = fire.map((e) => e.steer);
|
||||||
|
|
||||||
// Fire the wake (every marker as the turn's input appends). A 409 =
|
// Fire the wake with cargo still registered. A 409 releases its
|
||||||
// a client turn raced in between idle and firing — push the cargo
|
// vehicle flag and retries after the existing backoff, without
|
||||||
// back and retry after backoff (the racing turn's own settlement
|
// appending another registry entry.
|
||||||
// re-pumps if it beats us to the re-queue).
|
|
||||||
const status = await s.turnRunner(chatId, { type: "steers", steers }).catch((err: unknown) => {
|
const status = await s.turnRunner(chatId, { type: "steers", steers }).catch((err: unknown) => {
|
||||||
console.error(`[steer ${steers.map((x) => x.id).join(",")}] wake turn crashed:`, err);
|
console.error(`[steer ${steers.map((x) => x.id).join(",")}] wake turn crashed:`, err);
|
||||||
return -1;
|
return -1;
|
||||||
});
|
});
|
||||||
if (status === 403) {
|
const saved = stores.chatStore.getChat(chatId);
|
||||||
// Consent may have changed after the last gate. Preserve completed
|
if (saved) noteSteersDurable(chatId, saved.messages);
|
||||||
// job/result ownership and wait for an explicit connection change.
|
const owned = fire.filter((entry) => s.queues.get(chatId)?.includes(entry));
|
||||||
const back = s.queues.get(chatId) ?? [];
|
if (status === 403 || status === 409) {
|
||||||
back.push(...fire);
|
for (const entry of owned) { entry.waking = false; entry.wakeAgent = undefined; }
|
||||||
s.queues.set(chatId, back);
|
if (status === 403) { parked = true; return; }
|
||||||
parked = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (status === 409) {
|
|
||||||
const back = s.queues.get(chatId) ?? [];
|
|
||||||
back.push(...fire);
|
|
||||||
s.queues.set(chatId, back);
|
|
||||||
await delay(RETRY_BACKOFF_MS[Math.min(attempt++, RETRY_BACKOFF_MS.length - 1)]!);
|
await delay(RETRY_BACKOFF_MS[Math.min(attempt++, RETRY_BACKOFF_MS.length - 1)]!);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
attempt = 0;
|
attempt = 0;
|
||||||
|
for (const entry of owned) {
|
||||||
if (status === 200) {
|
if (status === 200 && !jobIdOf(entry.steer)) {
|
||||||
for (const entry of fire) {
|
// Preserve the non-job probe handoff's acceptance semantics.
|
||||||
finalizeSteer(entry.steer);
|
completeEntry(chatId, entry);
|
||||||
entry.resolve();
|
} else if (status !== 200 || !isTurnRunning(chatId)) {
|
||||||
}
|
deferOrReject(chatId, entry, new Error(`wake turn did not persist steer (status ${status})`));
|
||||||
} else {
|
|
||||||
const err = new Error(`wake turn failed with status ${status}`);
|
|
||||||
for (const entry of fire) {
|
|
||||||
failSteer(entry.steer, err);
|
|
||||||
entry.reject(err);
|
|
||||||
}
|
}
|
||||||
|
// An accepted live turn still owns its cargo until canonical save
|
||||||
|
// or settlement. HTTP 200 alone is not a durable marker receipt.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -614,7 +609,7 @@ async function pump(chatId: string): Promise<void> {
|
|||||||
// a live run are NOT stragglers (settleSteers owns their resurrection)
|
// a live run are NOT stragglers (settleSteers owns their resurrection)
|
||||||
// — restarting for them would just park in waitForIdle forever.
|
// — restarting for them would just park in waitForIdle forever.
|
||||||
const q = state().queues.get(chatId) ?? [];
|
const q = state().queues.get(chatId) ?? [];
|
||||||
if (!parked && automaticFollowUpAllowed(chatId, state().liveAgents.get(chatId)) && q.some((e) => e.handedTo === undefined)) void pump(chatId);
|
if (!parked && automaticFollowUpAllowed(chatId, state().liveAgents.get(chatId)) && q.some((e) => e.handedTo === undefined && !e.waking && !e.deferred)) void pump(chatId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,25 +633,56 @@ state().stopConnectionWatch = onSubscriptionChange(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Flip the job to its final status now that the lesson has resumed. */
|
function deferOrReject(chatId: string, entry: QueuedSteer, error: Error): void {
|
||||||
function finalizeJob(jobId: string): void {
|
entry.waking = false;
|
||||||
const job = stores.jobStore.getJob(jobId);
|
entry.wakeAgent = undefined;
|
||||||
if (!job || job.status !== "running") return;
|
if (jobIdOf(entry.steer)) {
|
||||||
const result = job.result;
|
entry.deferred = true;
|
||||||
const failed = result ? isSubagentFailure(job.kind, result as never) : true;
|
console.error(`[steer ${entry.steer.id}] delivery remains pending:`, error.message);
|
||||||
stores.jobStore.updateJob(jobId, {
|
} else {
|
||||||
status: failed ? "error" : "done",
|
removeEntry(chatId, entry);
|
||||||
activity: failed ? "failed" : "delivered",
|
entry.reject(error);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function failJob(jobId: string, err: Error): void {
|
/** Acknowledge session delivery without changing the worker outcome. */
|
||||||
// An aborted job stays aborted even when its (abort) steer
|
function finalizeJob(jobId: string): void {
|
||||||
// delivery fails — the user's action is the terminal fact, not the
|
try {
|
||||||
// delivery error. Non-running records are never overwritten here.
|
let job = stores.jobStore.getJob(jobId);
|
||||||
const job = stores.jobStore.getJob(jobId);
|
if (!job) return;
|
||||||
if (!job || job.status !== "running") return;
|
if (!job.delivery) job = normalizeLegacyResult(job);
|
||||||
stores.jobStore.updateJob(jobId, { status: "error", error: err.message, activity: "delivery failed" });
|
if (job.delivery?.status !== "pending") return;
|
||||||
|
stores.jobStore.updateJob(jobId, { delivery: { ...job.delivery, status: "delivered" } });
|
||||||
|
} catch (error) {
|
||||||
|
// The conversation is already durable. A bookkeeping failure must
|
||||||
|
// never replay that marker or turn a successful worker into a failure.
|
||||||
|
console.error(`[job ${jobId}] delivery acknowledgement failed:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLegacyResult(job: JobRecord): JobRecord {
|
||||||
|
if (job.delivery || job.status !== "running" || !job.result) return job;
|
||||||
|
const failed = isSubagentFailure(job.kind, job.result as never);
|
||||||
|
return stores.jobStore.updateJob(job.id, {
|
||||||
|
status: failed ? "error" : "done", activity: failed ? "failed" : "finished",
|
||||||
|
delivery: { kind: "result", status: "pending" },
|
||||||
|
}) ?? job;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore known obligations only; old terminal records are never replayed. */
|
||||||
|
function restorePendingDeliveries(chatId: string): void {
|
||||||
|
const record = stores.chatStore.getChat(chatId);
|
||||||
|
if (!record) return;
|
||||||
|
noteSteersDurable(chatId, record.messages);
|
||||||
|
for (const stored of stores.jobStore.listJobs(chatId)) {
|
||||||
|
const job = isJobLive(stored.id) ? stored : normalizeLegacyResult(stored);
|
||||||
|
if (job.delivery?.status !== "pending") continue;
|
||||||
|
const steer = job.delivery.kind === "abort" ? buildAbortSteer(job)
|
||||||
|
: job.result ? buildSubagentSteer(job, job.result as never) : undefined;
|
||||||
|
if (!steer) continue;
|
||||||
|
if (steerDelivered(record.messages, steer.id)) finalizeSteer(steer);
|
||||||
|
else queueSteer(chatId, steer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -669,7 +695,7 @@ function failJob(jobId: string, err: Error): void {
|
|||||||
async function waitForIdle(chatId: string): Promise<void> {
|
async function waitForIdle(chatId: string): Promise<void> {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const record = stores.chatStore.getChat(chatId);
|
const record = stores.chatStore.getChat(chatId);
|
||||||
if (!record) return;
|
if (!record || !(state().queues.get(chatId) ?? []).some((entry) => !entry.waking && !entry.deferred && !entry.handedTo)) return;
|
||||||
const blocked = isTurnRunning(chatId) || danglingQuestionOf(record.messages) !== null;
|
const blocked = isTurnRunning(chatId) || danglingQuestionOf(record.messages) !== null;
|
||||||
if (!blocked) {
|
if (!blocked) {
|
||||||
await delay(GRACE_MS);
|
await delay(GRACE_MS);
|
||||||
@@ -699,9 +725,9 @@ const SWEEP_THROTTLE_MS = 30_000;
|
|||||||
* hung-but-alive loops beside themselves — the figure-maker incident,
|
* hung-but-alive loops beside themselves — the figure-maker incident,
|
||||||
* 2026-09-18). A record WITH a live registration is provably executing
|
* 2026-09-18). A record WITH a live registration is provably executing
|
||||||
* in THIS process — never touched, however old: a hung first model call
|
* in THIS process — never touched, however old: a hung first model call
|
||||||
* (zero usage ever billed) stays honestly "running" in the dock until
|
* stays honestly running until END kills it. Terminal workers with known
|
||||||
* END kills it. The abort registration spans loop start to delivery
|
* pending delivery are restored independently; old terminal records without
|
||||||
* completion, so a live registration can never belong to a dead run.
|
* delivery metadata are never inferred to owe a delivery.
|
||||||
*/
|
*/
|
||||||
/** Re-run a job's sub-agent loop — exposed for the sweep's tests (the
|
/** Re-run a job's sub-agent loop — exposed for the sweep's tests (the
|
||||||
* /api/jobs poll is its only production caller, disabled under NODE_ENV=test). */
|
* /api/jobs poll is its only production caller, disabled under NODE_ENV=test). */
|
||||||
@@ -713,18 +739,14 @@ export function recoverStaleJobs(): void {
|
|||||||
try {
|
try {
|
||||||
for (const chat of stores.chatStore.listChats()) {
|
for (const chat of stores.chatStore.listChats()) {
|
||||||
for (const job of stores.jobStore.listJobs(chat.id)) {
|
for (const job of stores.jobStore.listJobs(chat.id)) {
|
||||||
if (job.status !== "running") continue;
|
if (isJobLive(job.id)) continue; // never re-run beside live computation
|
||||||
if (isJobLive(job.id)) continue; // provably executing here — never re-run beside it
|
|
||||||
if (!stores.chatStore.getChat(job.chatId)) continue;
|
if (!stores.chatStore.getChat(job.chatId)) continue;
|
||||||
if (isTurnRunning(job.chatId)) continue; // a wake turn may be delivering it right now
|
if (isTurnRunning(job.chatId)) continue;
|
||||||
if (job.result) {
|
const recovered = normalizeLegacyResult(job);
|
||||||
// The wake never fired (or crashed mid-flight): re-deliver.
|
if (recovered.delivery?.status === "pending") {
|
||||||
const steer = buildSubagentSteer(job, job.result as never);
|
restorePendingDeliveries(job.chatId);
|
||||||
stores.jobStore.updateJob(job.id, { activity: "recovering: re-delivering" });
|
void pump(job.chatId);
|
||||||
void deliverJobResult(steer).catch(() => {
|
} else if (job.status === "running" && !job.result) {
|
||||||
/* deliverJobResult marks the job error on failure */
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// The sub-agent loop itself died with the process: re-spawn.
|
// The sub-agent loop itself died with the process: re-spawn.
|
||||||
if (!s.respawn) continue;
|
if (!s.respawn) continue;
|
||||||
stores.jobStore.updateJob(job.id, { activity: "recovering: re-running" });
|
stores.jobStore.updateJob(job.id, { activity: "recovering: re-running" });
|
||||||
|
|||||||
@@ -245,10 +245,17 @@ export function validateChatSettings(settings: ChatSettings): SettingsValidation
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateModelsSettings(settings: ModelsSettings): SettingsValidation {
|
/** Validate introduced/replaced model selections, not unchanged saved pins.
|
||||||
|
* A disconnected subscription loses its available catalog, but its stored
|
||||||
|
* selections must not prevent repairing another slot or editing thinking.
|
||||||
|
* Only the SAME slot's provider+id can pass through; runtime admission stays
|
||||||
|
* separate. Callers without a stored section retain strict catalog checking. */
|
||||||
|
export function validateModelsSettings(settings: ModelsSettings, stored?: ModelsSettings): SettingsValidation {
|
||||||
for (const name of MODEL_SLOTS) {
|
for (const name of MODEL_SLOTS) {
|
||||||
const slot = settings[name];
|
const slot = settings[name];
|
||||||
if (!slot) continue;
|
if (!slot) continue;
|
||||||
|
const previous = stored?.[name]?.model;
|
||||||
|
if (slot.model && previous && slot.model.provider === previous.provider && slot.model.id === previous.id) continue;
|
||||||
const verdict = validateSlotSpec(name, slot);
|
const verdict = validateSlotSpec(name, slot);
|
||||||
if (!verdict.ok) return verdict;
|
if (!verdict.ok) return verdict;
|
||||||
}
|
}
|
||||||
@@ -280,9 +287,8 @@ function validateSlotSpec(name: ModelSlotName, slot: { model?: { provider: strin
|
|||||||
// runtime — and the clamped value is what lands in the record. The
|
// runtime — and the clamped value is what lands in the record. The
|
||||||
// server is the truth: the record shows the clamped level after write.
|
// server is the truth: the record shows the clamped level after write.
|
||||||
|
|
||||||
/** Clamp one level against one model (identity when the model is unknown
|
/** Clamp one level against one model. Preserve the preference when metadata
|
||||||
* or the level already supported — the existence gate rejects unknown
|
* is unavailable for an unchanged saved pin; runtime clamps after reconnect. */
|
||||||
* models separately, so this only ever sees real ones in practice). */
|
|
||||||
function clampLevelFor(provider: string, id: string, level: ThinkingLevel): ThinkingLevel {
|
function clampLevelFor(provider: string, id: string, level: ThinkingLevel): ThinkingLevel {
|
||||||
const model = connectionModel(provider, id);
|
const model = connectionModel(provider, id);
|
||||||
return model ? clampThinkingLevel(model, level) : level;
|
return model ? clampThinkingLevel(model, level) : level;
|
||||||
|
|||||||
+28
-14
@@ -1,6 +1,4 @@
|
|||||||
import type { Agent, AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core";
|
import type { Agent, AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core";
|
||||||
import type { SteerMessage } from "../../core/contracts.ts";
|
|
||||||
import { noteSteerVisible } from "../jobs.ts";
|
|
||||||
import { ToolCallIdUniquifier } from "./call-ids.ts";
|
import { ToolCallIdUniquifier } from "./call-ids.ts";
|
||||||
import type { ChatGPTRequestCycle } from "../../agent/chatgpt-request-cycle.ts";
|
import type { ChatGPTRequestCycle } from "../../agent/chatgpt-request-cycle.ts";
|
||||||
|
|
||||||
@@ -79,6 +77,10 @@ export interface PiTurnSpec {
|
|||||||
* event stream itself is verbatim — provider errors ride their
|
* event stream itself is verbatim — provider errors ride their
|
||||||
* message's stopReason/errorMessage through untouched). */
|
* message's stopReason/errorMessage through untouched). */
|
||||||
onError?: (error: unknown) => string;
|
onError?: (error: unknown) => string;
|
||||||
|
/** Report settlement failure while the views are still open. */
|
||||||
|
onSettleError?: (error: unknown) => string;
|
||||||
|
/** Release liveness even if the normal finish seam throws. */
|
||||||
|
onSettleFailure?: (error: unknown, reportedErrorText?: string) => void;
|
||||||
/** The canonical end-of-turn seam: persist + usage + extraction + endTurn.
|
/** The canonical end-of-turn seam: persist + usage + extraction + endTurn.
|
||||||
* Receives the agent's FULL final state (native messages) — the caller
|
* Receives the agent's FULL final state (native messages) — the caller
|
||||||
* persists the slice past its own base count. */
|
* persists the slice past its own base count. */
|
||||||
@@ -119,11 +121,12 @@ export interface PiTurnHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||||
const { agent, onError, onFinish, usedToolCallIds, updateFlushMs = 100, requestCycle } = spec;
|
const { agent, onError, onFinish, onSettleError, onSettleFailure, usedToolCallIds, updateFlushMs = 100, requestCycle } = spec;
|
||||||
const uniquifier = usedToolCallIds ? new ToolCallIdUniquifier(usedToolCallIds) : undefined;
|
const uniquifier = usedToolCallIds ? new ToolCallIdUniquifier(usedToolCallIds) : undefined;
|
||||||
|
|
||||||
let settled = false;
|
let settled = false;
|
||||||
let sawAbort = false;
|
let sawAbort = false;
|
||||||
|
let reportedErrorText: string | undefined;
|
||||||
|
|
||||||
// THE PER-TURN STATE LOG (memory-only — the settle-only disk writes
|
// THE PER-TURN STATE LOG (memory-only — the settle-only disk writes
|
||||||
// are unchanged). COMPACTION LAW (owner, 2026-10-09, supersedes the
|
// are unchanged). COMPACTION LAW (owner, 2026-10-09, supersedes the
|
||||||
@@ -221,11 +224,22 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
|||||||
// messages of its own (the prompt is composed per turn), so dropping
|
// messages of its own (the prompt is composed per turn), so dropping
|
||||||
// the role wholesale is exact; revisit if prompt-updates ever land.
|
// the role wholesale is exact; revisit if prompt-updates ever land.
|
||||||
const settleWith = () => {
|
const settleWith = () => {
|
||||||
const settled_ = [...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system");
|
try {
|
||||||
onFinish((uniquifier?.remapSettled(settled_) as AgentMessage[]) ?? settled_, sawAbort);
|
const settled_ = [...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system");
|
||||||
|
onFinish((uniquifier?.remapSettled(settled_) as AgentMessage[]) ?? settled_, sawAbort);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[turn] settlement failed:", err);
|
||||||
|
try { onSettleFailure?.(err, reportedErrorText); }
|
||||||
|
catch (cleanupError) { console.error("[turn] settlement cleanup failed:", cleanupError); }
|
||||||
|
if (reportedErrorText === undefined) {
|
||||||
|
reportedErrorText = onSettleError?.(err) ?? "An error occurred.";
|
||||||
|
send({ type: "error", errorText: reportedErrorText });
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handle = (event: AgentEvent) => {
|
const handle = (event: AgentEvent) => {
|
||||||
|
if (settled) return;
|
||||||
// THE LACAN FIX: ids uniquify HERE, before any view — the one seam
|
// THE LACAN FIX: ids uniquify HERE, before any view — the one seam
|
||||||
// every consumer downstream shares. No uniquifier → verbatim.
|
// every consumer downstream shares. No uniquifier → verbatim.
|
||||||
const uniqued = uniquifier ? uniquifier.event(event) : event;
|
const uniqued = uniquifier ? uniquifier.event(event) : event;
|
||||||
@@ -239,15 +253,11 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
|||||||
if (event.type === "message_end") {
|
if (event.type === "message_end") {
|
||||||
const m = event.message as { role?: string; stopReason?: string } | undefined;
|
const m = event.message as { role?: string; stopReason?: string } | undefined;
|
||||||
if (m?.role === "assistant" && m.stopReason === "aborted") sawAbort = true;
|
if (m?.role === "assistant" && m.stopReason === "aborted") sawAbort = true;
|
||||||
// Dock fix (owner ruling 2026-09-26): an injected steer marker's
|
|
||||||
// message_end = the card is visible to the learner — flip the
|
|
||||||
// job's bookkeeping now (the turn may keep teaching for minutes;
|
|
||||||
// settleSteers re-runs the flip as a no-op idempotently).
|
|
||||||
if (m?.role === "custom") noteSteerVisible(event.message as SteerMessage);
|
|
||||||
}
|
}
|
||||||
if (event.type === "agent_end") {
|
if (event.type === "agent_end") {
|
||||||
closeAll();
|
// Persist/report before the terminal closes every view to new frames.
|
||||||
settleWith();
|
settleWith();
|
||||||
|
closeAll();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
requestCycle?.bindRelease(handle);
|
requestCycle?.bindRelease(handle);
|
||||||
@@ -273,11 +283,15 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
|||||||
// stream as stopReason "error"): surface, close, persist. If the
|
// stream as stopReason "error"): surface, close, persist. If the
|
||||||
// agent already settled (agent_end reached before the throw),
|
// agent already settled (agent_end reached before the throw),
|
||||||
// the seam has run — never settle twice.
|
// the seam has run — never settle twice.
|
||||||
if (settled) return;
|
if (settled) {
|
||||||
|
console.error("[turn] error after settlement:", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error("[turn] agent loop failed:", err);
|
console.error("[turn] agent loop failed:", err);
|
||||||
send({ type: "error", errorText: onError?.(err) ?? "An error occurred." });
|
reportedErrorText = onError?.(err) ?? "An error occurred.";
|
||||||
closeAll();
|
send({ type: "error", errorText: reportedErrorText });
|
||||||
settleWith();
|
settleWith();
|
||||||
|
closeAll();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { emit } from "../core/events.ts";
|
import { emit } from "../core/events.ts";
|
||||||
|
import { clearTurnFailure } from "./turn-runs.ts";
|
||||||
|
|
||||||
interface LhGlobal {
|
interface LhGlobal {
|
||||||
__lhTurnRegistry?: Map<string, number>;
|
__lhTurnRegistry?: Map<string, number>;
|
||||||
@@ -43,15 +44,17 @@ const MAX_TURN_MS = 30 * 60_000;
|
|||||||
*/
|
*/
|
||||||
export function beginTurn(chatId: string): boolean {
|
export function beginTurn(chatId: string): boolean {
|
||||||
if (isTurnRunning(chatId)) return false;
|
if (isTurnRunning(chatId)) return false;
|
||||||
|
clearTurnFailure(chatId);
|
||||||
inFlight.set(chatId, Date.now());
|
inFlight.set(chatId, Date.now());
|
||||||
emit("turn-started", { chatId });
|
emit("turn-started", { chatId });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark the chat's turn as finished (runs in onFinish, aborted or not —
|
* Mark the chat's turn as finished after normal settlement, or after the
|
||||||
* AFTER persistTurn, so "turn-settled" guarantees a subsequent fetch reads
|
* failure notice has been retained by settlement cleanup. A subsequent
|
||||||
* durable truth). Emits only when an entry was actually present: a
|
* fetch reads durable truth, never a promise that an unsaved reply landed.
|
||||||
|
* Emits only when an entry was actually present: a
|
||||||
* defensive no-op endTurn must not fire a spurious settle.
|
* defensive no-op endTurn must not fire a spurious settle.
|
||||||
*/
|
*/
|
||||||
export function endTurn(chatId: string): void {
|
export function endTurn(chatId: string): void {
|
||||||
|
|||||||
+30
-5
@@ -13,7 +13,9 @@
|
|||||||
* Registration lifecycle: chat-handler registers the handle the moment
|
* Registration lifecycle: chat-handler registers the handle the moment
|
||||||
* the turn starts (after the 409 guard) and its onFinish unregisters LAST
|
* the turn starts (after the 409 guard) and its onFinish unregisters LAST
|
||||||
* — after persist, usage, extraction, and endTurn — so anything that sees
|
* — after persist, usage, extraction, and endTurn — so anything that sees
|
||||||
* "no running turn" also sees an already-persisted record. The per-chat
|
* "no running turn" sees durable record truth. Failed settlement also
|
||||||
|
* retains its small notice before unregistering, so late attachments do
|
||||||
|
* not mistake an unsaved reply for success. The per-chat
|
||||||
* 409 guard means a chatId can hold at most one handle, and beginTurn's
|
* 409 guard means a chatId can hold at most one handle, and beginTurn's
|
||||||
* 30-minute TTL bounds the pathological never-settling case the same way
|
* 30-minute TTL bounds the pathological never-settling case the same way
|
||||||
* it does for the liveness map: if onFinish never fires, the stale entry
|
* it does for the liveness map: if onFinish never fires, the stale entry
|
||||||
@@ -30,6 +32,7 @@ import type { PiTurnHandle } from "./pi/ui-stream.ts";
|
|||||||
|
|
||||||
interface LhTurnRunsGlobal {
|
interface LhTurnRunsGlobal {
|
||||||
__lhTurnRuns?: Map<string, PiTurnHandle>;
|
__lhTurnRuns?: Map<string, PiTurnHandle>;
|
||||||
|
__lhTurnFailures?: Map<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function runs(): Map<string, PiTurnHandle> {
|
function runs(): Map<string, PiTurnHandle> {
|
||||||
@@ -37,6 +40,25 @@ function runs(): Map<string, PiTurnHandle> {
|
|||||||
return (g.__lhTurnRuns ??= new Map<string, PiTurnHandle>());
|
return (g.__lhTurnRuns ??= new Map<string, PiTurnHandle>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One small, transient notice per failed session. Never stores messages.
|
||||||
|
* Cleared on the next accepted begin, session deletion, or process exit. */
|
||||||
|
function failures(): Map<string, string> {
|
||||||
|
const g = globalThis as typeof globalThis & LhTurnRunsGlobal;
|
||||||
|
return (g.__lhTurnFailures ??= new Map<string, string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberTurnFailure(chatId: string, errorText: string): void {
|
||||||
|
failures().set(chatId, errorText);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function turnFailure(chatId: string): string | undefined {
|
||||||
|
return failures().get(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTurnFailure(chatId: string): void {
|
||||||
|
failures().delete(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Register the chat's running turn (chat-handler, right after the turn
|
/** Register the chat's running turn (chat-handler, right after the turn
|
||||||
* starts). One per chat by construction — the 409 guard. */
|
* starts). One per chat by construction — the 409 guard. */
|
||||||
export function registerTurn(chatId: string, handle: PiTurnHandle): void {
|
export function registerTurn(chatId: string, handle: PiTurnHandle): void {
|
||||||
@@ -67,11 +89,14 @@ export function abortTurn(chatId: string): { ok: true } | { ok: false; reason: "
|
|||||||
/** The cheap terminal when GET /api/chats/[id]/stream finds no running
|
/** The cheap terminal when GET /api/chats/[id]/stream finds no running
|
||||||
* turn (raced settle, or the client checked turnInFlight a beat too
|
* turn (raced settle, or the client checked turnInFlight a beat too
|
||||||
* late): a same-dialect SSE response whose single chunk tells the client
|
* late): a same-dialect SSE response whose single chunk tells the client
|
||||||
* "nothing to attach to — the record is current, refetch it". Chosen as
|
* "nothing to attach to — refetch durable truth". A retained settlement
|
||||||
* an EVENT (not a status code) so the reattach path consumes ONE wire
|
* failure is delivered first, including when no view attached in time.
|
||||||
|
* Chosen as an EVENT (not a status code), consuming ONE wire
|
||||||
* protocol end to end. */
|
* protocol end to end. */
|
||||||
export function settledTurnResponse(): Response {
|
export function settledTurnResponse(chatId?: string): Response {
|
||||||
return new Response('data: {"type":"turn-settled"}\n\ndata: [DONE]\n\n', {
|
const errorText = chatId ? turnFailure(chatId) : undefined;
|
||||||
|
const notice = errorText === undefined ? "" : `data: ${JSON.stringify({ type: "error", errorText })}\n\n`;
|
||||||
|
return new Response(`${notice}data: {"type":"turn-settled"}\n\ndata: [DONE]\n\n`, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
"Cache-Control": "no-cache, no-transform",
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* The chat-turn services — one named, error-contained seam per turn
|
* The chat-turn services — one named seam per turn
|
||||||
* responsibility, extracted from the chat-handler so it composes instead of
|
* responsibility, extracted from the chat-handler so it composes instead of
|
||||||
* implementing. Each takes its dependencies explicitly (storage ports, the
|
* implementing. Each takes its dependencies explicitly (storage ports, the
|
||||||
* job runner) — no module here imports an adapter.
|
* job runner) — no module here imports an adapter.
|
||||||
@@ -26,6 +26,14 @@ import type { ChatStore, GraphStore } from "../core/ports.ts";
|
|||||||
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
|
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||||
import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/settings.ts";
|
import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/settings.ts";
|
||||||
|
|
||||||
|
/** Distinguish a failed canonical save from later bookkeeping failures. */
|
||||||
|
export class TurnPersistenceError extends Error {
|
||||||
|
constructor(cause: unknown) {
|
||||||
|
super("The reply could not be saved.", { cause });
|
||||||
|
this.name = "TurnPersistenceError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The canonical state write (Wave B, owner-approved 2026-09-26): append the
|
* The canonical state write (Wave B, owner-approved 2026-09-26): append the
|
||||||
* turn's own messages — the agent loop's native history, provider/model/
|
* turn's own messages — the agent loop's native history, provider/model/
|
||||||
@@ -45,7 +53,8 @@ import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/setti
|
|||||||
* - Plan blocks: one frozen snapshot per successful plan_init call,
|
* - Plan blocks: one frozen snapshot per successful plan_init call,
|
||||||
* idempotent (persist replays are safe).
|
* idempotent (persist replays are safe).
|
||||||
*
|
*
|
||||||
* Returns the stamped new messages (usage provenance reads them).
|
* Returns the stamped new messages (usage provenance reads them). Throws
|
||||||
|
* TurnPersistenceError on failure so the runner can report it before DONE.
|
||||||
*/
|
*/
|
||||||
export function persistTurn(
|
export function persistTurn(
|
||||||
chatStore: ChatStore,
|
chatStore: ChatStore,
|
||||||
@@ -56,7 +65,7 @@ export function persistTurn(
|
|||||||
): LhMessage[] {
|
): LhMessage[] {
|
||||||
try {
|
try {
|
||||||
const record = chatStore.getChat(chatId);
|
const record = chatStore.getChat(chatId);
|
||||||
if (!record) return [];
|
if (!record) throw new Error(`Chat not found: ${chatId}`);
|
||||||
const stamped: LhMessage[] = [];
|
const stamped: LhMessage[] = [];
|
||||||
for (const m of turnMessages.slice(baseCount)) {
|
for (const m of turnMessages.slice(baseCount)) {
|
||||||
const message = m as LhMessage;
|
const message = m as LhMessage;
|
||||||
@@ -79,7 +88,7 @@ export function persistTurn(
|
|||||||
return stamped;
|
return stamped;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
|
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
|
||||||
return [];
|
throw new TurnPersistenceError(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts";
|
||||||
|
import { webSubscriptionOwner, webSubscriptionsEnabled, webSubscriptionStatus } from "./web-subscriptions.ts";
|
||||||
|
|
||||||
|
const HEADERS = { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff" };
|
||||||
|
/** Same-origin browser controls, not user authentication. Web/dev deliberately
|
||||||
|
* retains the existing shared-server/API-key trust model. Never trust forwarded
|
||||||
|
* origins or add CORS grants here. A public deployment needs its own access gate. */
|
||||||
|
export function subscriptionRequestOrigin(request: Request): string | undefined {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const host = request.headers.get("host") ?? url.host;
|
||||||
|
const addressed = new URL(`${url.protocol}//${host}`);
|
||||||
|
if (!["http:", "https:"].includes(addressed.protocol) || addressed.username || addressed.password || addressed.pathname !== "/" || addressed.search || addressed.hash) return;
|
||||||
|
return addressed.origin;
|
||||||
|
} catch { return; }
|
||||||
|
}
|
||||||
|
function authorized(request: Request): boolean {
|
||||||
|
const origin = subscriptionRequestOrigin(request);
|
||||||
|
if (!origin || request.headers.get("x-learnharness-subscriptions") !== "1") return false;
|
||||||
|
if (request.headers.get("sec-fetch-site") === "cross-site") return false;
|
||||||
|
const supplied = request.headers.get("origin");
|
||||||
|
return request.method === "GET" ? !supplied || supplied === origin : supplied === origin;
|
||||||
|
}
|
||||||
|
function error(message: string, status: number) { return Response.json({ error: message }, { status, headers: HEADERS }); }
|
||||||
|
export async function subscriptionControlsResponse(request: Request): Promise<Response> {
|
||||||
|
if (!webSubscriptionsEnabled()) return error("Web subscription controls are unavailable in desktop-owned mode.", 404);
|
||||||
|
if (!authorized(request)) return error("Subscription controls require a same-origin LearnHarness request.", 403);
|
||||||
|
try {
|
||||||
|
if (request.method === "GET") return Response.json(await webSubscriptionStatus(), { headers: HEADERS });
|
||||||
|
if (request.method !== "POST") return error("Unsupported method.", 405);
|
||||||
|
if (request.headers.get("content-type")?.split(";")[0]?.trim() !== "application/json") return error("Expected JSON.", 415);
|
||||||
|
// Bound both advertised and streamed input before parsing commands.
|
||||||
|
if (Number(request.headers.get("content-length")) > 16384) return error("Request too large.", 413);
|
||||||
|
const reader = request.body?.getReader();
|
||||||
|
if (!reader) return error("Expected a command.", 400);
|
||||||
|
const chunks: Uint8Array[] = []; let size = 0;
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const next = await reader.read(); if (next.done) break;
|
||||||
|
size += next.value.byteLength;
|
||||||
|
if (size > 16384) { await reader.cancel(); return error("Request too large.", 413); }
|
||||||
|
chunks.push(next.value);
|
||||||
|
}
|
||||||
|
} finally { reader.releaseLock(); }
|
||||||
|
const command: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||||
|
if (!command || typeof command !== "object" || Array.isArray(command)) return error("Invalid command.", 400);
|
||||||
|
const fields = command as Record<string, unknown>;
|
||||||
|
const providerId = fields.providerId;
|
||||||
|
if (providerId !== "chatgpt" && !NATIVE_SUBSCRIPTIONS.some((provider) => provider.providerId === providerId)) return error("Unsupported connection.", 400);
|
||||||
|
const allowed = providerId === "chatgpt" ? ["login", "cancel", "select", "disconnect", "logout", "consent"] : ["login", "cancel", "respond", "open", "retry", "disconnect", "consent"];
|
||||||
|
if (typeof fields.op !== "string" || !allowed.includes(fields.op)) return error("Unsupported control.", 400);
|
||||||
|
return Response.json(await webSubscriptionOwner().control(providerId as string, fields, subscriptionRequestOrigin(request)!), { headers: HEADERS });
|
||||||
|
} catch {
|
||||||
|
return error("The subscription connection could not be changed. Retry or reconnect in Providers.", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** One-use same-origin navigation. Full provider authorization URLs go only to
|
||||||
|
* a top-level browser redirect, never status JSON, the SSE bus or application JS. */
|
||||||
|
export async function subscriptionBrowserResponse(request: Request): Promise<Response> {
|
||||||
|
if (!webSubscriptionsEnabled()) return error("Unavailable.", 404);
|
||||||
|
const origin = subscriptionRequestOrigin(request);
|
||||||
|
if (!origin || (request.method === "POST" ? !authorized(request) : request.headers.get("sec-fetch-site") !== "same-origin")) return error("Open sign-in from LearnHarness.", 403);
|
||||||
|
const id = new URL(request.url).searchParams.get("id");
|
||||||
|
if (!id || !/^[0-9a-f-]{36}$/.test(id)) return error("This sign-in action is no longer available.", 410);
|
||||||
|
const target = await webSubscriptionOwner().takeBrowserAction(id, origin);
|
||||||
|
if (!target) return error("This sign-in action is no longer available.", 410);
|
||||||
|
return new Response(null, { status: 303, headers: { ...HEADERS, Location: target } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/** Persistent Node web/dev owner. Never imported by client code. Desktop keeps
|
||||||
|
* its existing main-process owner; a broken desktop IPC channel cannot fall
|
||||||
|
* back to these files. One server process must own each configured auth dir. */
|
||||||
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
|
import { AUTH_FILE } from "../../config.ts";
|
||||||
|
import { createChatGPT, fileStore } from "../../electron/chatgpt.cjs";
|
||||||
|
import { createSubscriptions } from "../../electron/subscriptions.cjs";
|
||||||
|
import { chatGPTControl } from "../../electron/chatgpt-ipc.cjs";
|
||||||
|
import { subscriptionControl } from "../../electron/subscription-ipc.cjs";
|
||||||
|
import { emit } from "../core/events.ts";
|
||||||
|
import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
|
||||||
|
import type { NativeSubscriptionSnapshot } from "../core/contracts/subscriptions.ts";
|
||||||
|
import { configureWebSubscriptionRuntime, publishWebSubscriptionPacket, webSubscriptionsEnabled, type SubscriptionRuntimeChannel as Channel, type SubscriptionRuntimePacket as Packet } from "../agent/subscription-runtime.ts";
|
||||||
|
export { webSubscriptionsEnabled } from "../agent/subscription-runtime.ts";
|
||||||
|
interface BrowserAction { target: string; origin: string; scope: string; expires: number; current(): Promise<boolean> }
|
||||||
|
const KEY = Symbol.for("lh.webSubscriptions");
|
||||||
|
interface Registry {
|
||||||
|
owner?: ReturnType<typeof createWebSubscriptionOwner>;
|
||||||
|
}
|
||||||
|
function registry(): Registry {
|
||||||
|
const global = globalThis as typeof globalThis & { [KEY]?: Registry };
|
||||||
|
return global[KEY] ??= {};
|
||||||
|
}
|
||||||
|
function publish(channel: Channel, packet: Packet) {
|
||||||
|
publishWebSubscriptionPacket(channel, packet);
|
||||||
|
emit("subscriptions-changed", {});
|
||||||
|
}
|
||||||
|
async function loadSDK() {
|
||||||
|
const [sdk, copilot, xai] = await Promise.all([
|
||||||
|
import("@earendil-works/pi-ai"),
|
||||||
|
import("@earendil-works/pi-ai/providers/github-copilot"),
|
||||||
|
import("@earendil-works/pi-ai/providers/xai"),
|
||||||
|
]);
|
||||||
|
return { ...sdk, ...copilot, ...xai };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Injectable factories/stores keep all test authentication offline. */
|
||||||
|
export function createWebSubscriptionOwner(options: {
|
||||||
|
directory: string;
|
||||||
|
chatgptStore?: Parameters<typeof createChatGPT>[0]["store"];
|
||||||
|
sdk?: unknown;
|
||||||
|
onPacket?: (channel: Channel, packet: Packet) => void;
|
||||||
|
} ) {
|
||||||
|
const notify = options.onPacket ?? (() => {});
|
||||||
|
const launches = new AsyncLocalStorage<string[]>();
|
||||||
|
const actions = new Map<string, BrowserAction>();
|
||||||
|
let chatGPTLaunch = 0;
|
||||||
|
const openExternal = async (target: string) => {
|
||||||
|
const captured = launches.getStore();
|
||||||
|
if (!captured || new URL(target).protocol !== "https:") throw new Error("No browser action is pending.");
|
||||||
|
captured.push(target);
|
||||||
|
};
|
||||||
|
const chatgpt = createChatGPT({
|
||||||
|
store: options.chatgptStore ?? fileStore(path.join(options.directory, "chatgpt-credentials.json")),
|
||||||
|
openExternal,
|
||||||
|
onChange: (packet?: Packet) => { if (packet) notify("lh:chatgpt", packet); },
|
||||||
|
});
|
||||||
|
const native = createSubscriptions({
|
||||||
|
directory: options.directory, sdk: options.sdk, loadSDK, openExternal,
|
||||||
|
onChange: (snapshot: NativeSubscriptionSnapshot) => notify("lh:subscriptions", { channel: "lh:subscriptions", op: "changed", snapshot }),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
chatgpt, native,
|
||||||
|
async control(providerId: string, command: Record<string, unknown>, origin: string) {
|
||||||
|
const targets: string[] = [];
|
||||||
|
const value = await launches.run(targets, () => providerId === "chatgpt"
|
||||||
|
? chatGPTControl(command, chatgpt)
|
||||||
|
: subscriptionControl({ ...command, providerId }, native));
|
||||||
|
let browserAction: string | undefined;
|
||||||
|
const target = targets[0];
|
||||||
|
if (target) {
|
||||||
|
const scope = providerId === "chatgpt" ? "chatgpt" : `${providerId}:${command.attemptId}:${command.actionId}`;
|
||||||
|
for (const [id, action] of actions) if (action.expires <= Date.now() || action.scope === scope) actions.delete(id);
|
||||||
|
const id = randomUUID();
|
||||||
|
const generation = providerId === "chatgpt" ? ++chatGPTLaunch : chatGPTLaunch;
|
||||||
|
const current = providerId === "chatgpt"
|
||||||
|
? async () => generation === chatGPTLaunch && (await chatgpt.status()).pending === true
|
||||||
|
: async () => (await native.status()).some((status: { providerId: string; interaction?: { attemptId: string; browser?: { id: string } } }) => status.providerId === providerId && status.interaction?.attemptId === command.attemptId && status.interaction?.browser?.id === command.actionId);
|
||||||
|
actions.set(id, { target, origin, scope, expires: Date.now() + 180000, current });
|
||||||
|
browserAction = `/api/subscriptions/browser?id=${id}`;
|
||||||
|
}
|
||||||
|
return { value, ...(browserAction ? { browserAction } : {}) };
|
||||||
|
},
|
||||||
|
async takeBrowserAction(id: string, origin: string): Promise<string | undefined> {
|
||||||
|
const action = actions.get(id);
|
||||||
|
if (!action || action.origin !== origin) return;
|
||||||
|
actions.delete(id);
|
||||||
|
if (action.expires <= Date.now() || !await action.current()) return;
|
||||||
|
return action.target;
|
||||||
|
},
|
||||||
|
close() { actions.clear(); chatgpt.close(); native.close(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function webSubscriptionOwner() {
|
||||||
|
if (!webSubscriptionsEnabled()) throw new Error("Web subscription controls are unavailable in desktop-owned mode.");
|
||||||
|
const state = registry();
|
||||||
|
if (!state.owner) {
|
||||||
|
state.owner = createWebSubscriptionOwner({ directory: path.dirname(AUTH_FILE), onPacket: publish });
|
||||||
|
process.once("exit", () => state.owner?.close());
|
||||||
|
}
|
||||||
|
return state.owner;
|
||||||
|
}
|
||||||
|
/** Private runtime calls only: no HTTP lease/token operation exists. */
|
||||||
|
export async function webSubscriptionRequest<T>(channel: Channel, op: string, fields: Record<string, unknown>): Promise<T> {
|
||||||
|
const owner = webSubscriptionOwner();
|
||||||
|
let value: unknown;
|
||||||
|
if (channel === "lh:chatgpt") {
|
||||||
|
if (op === "status") value = { status: await owner.chatgpt.status(), ...owner.chatgpt.stamp() };
|
||||||
|
else if (op === "models") value = await owner.chatgpt.models();
|
||||||
|
else if (op === "lease" && typeof fields.background === "boolean") value = await owner.chatgpt.lease(fields.background, fields);
|
||||||
|
else throw new Error("Invalid private operation.");
|
||||||
|
} else {
|
||||||
|
if (op === "snapshot") value = await owner.native.snapshot();
|
||||||
|
else if (op === "lease" && typeof fields.background === "boolean") value = await owner.native.lease(fields.scope, fields.background);
|
||||||
|
else throw new Error("Invalid private operation.");
|
||||||
|
}
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
configureWebSubscriptionRuntime(webSubscriptionRequest);
|
||||||
|
|
||||||
|
export async function webSubscriptionStatus(): Promise<{ chatgpt: ChatGPTStatus; native: NativeSubscriptionSnapshot["statuses"] }> {
|
||||||
|
const owner = webSubscriptionOwner();
|
||||||
|
const [chatgpt, native] = await Promise.all([owner.chatgpt.status(), owner.native.status()]);
|
||||||
|
return { chatgpt, native };
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ function createJob(init: CreateJobInit): JobRecord {
|
|||||||
kind: init.kind,
|
kind: init.kind,
|
||||||
chatId: init.chatId,
|
chatId: init.chatId,
|
||||||
toolCallId: init.toolCallId,
|
toolCallId: init.toolCallId,
|
||||||
|
...(init.input !== undefined ? { input: init.input } : {}),
|
||||||
status: "running",
|
status: "running",
|
||||||
activity: init.activity ?? "starting",
|
activity: init.activity ?? "starting",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
/** Actual shell functions with fake Electron/updater/filesystem. No app,
|
||||||
|
* profile, network, timer or updater download/install operation is run. */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createContext, runInContext } from "node:vm";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
|
||||||
|
const mainFile = fileURLToPath(new URL("../electron/main.cjs", import.meta.url));
|
||||||
|
const mainSource = readFileSync(mainFile, "utf8");
|
||||||
|
const mainRequire = createRequire(mainFile);
|
||||||
|
|
||||||
|
function fixture({ packaged = true, updaterMissing = false } = {}) {
|
||||||
|
let settings: string | Error = JSON.stringify({ version: 1, general: { autoUpdate: true } });
|
||||||
|
const settingsReads: string[] = [];
|
||||||
|
const order: string[] = [];
|
||||||
|
const intervals: { callback: () => void; delay: number }[] = [];
|
||||||
|
const handles = new Map<string, (event: unknown) => unknown>();
|
||||||
|
const windows: FakeWindow[] = [];
|
||||||
|
let enumerationFails = false;
|
||||||
|
let userData = "/fixture/custom-user-data";
|
||||||
|
let menu: { submenu?: { label?: string; click?: () => void }[] }[] = [];
|
||||||
|
class FakeUpdater extends EventEmitter {
|
||||||
|
checks = 0;
|
||||||
|
autoDownload = false;
|
||||||
|
autoInstallOnAppQuit = false;
|
||||||
|
result: object | null = { updateInfo: { version: "0.3.7" } };
|
||||||
|
failure: Error | null = null;
|
||||||
|
checkForUpdates() {
|
||||||
|
this.checks++;
|
||||||
|
this.emit("checking-for-update");
|
||||||
|
if (this.failure) return Promise.reject(this.failure);
|
||||||
|
if (this.result) this.emit("update-not-available", { version: "0.3.7" });
|
||||||
|
return Promise.resolve(this.result);
|
||||||
|
}
|
||||||
|
quitAndInstall() { order.push("install"); }
|
||||||
|
}
|
||||||
|
const updater = new FakeUpdater();
|
||||||
|
class FakeWebContents extends EventEmitter {
|
||||||
|
mainFrame = { url: "http://127.0.0.1:9000/" };
|
||||||
|
messages: { channel: string; value: unknown }[] = [];
|
||||||
|
loading = false;
|
||||||
|
fails = false;
|
||||||
|
send(channel: string, value: unknown) {
|
||||||
|
if (this.fails) throw new Error("window destroyed");
|
||||||
|
this.messages.push({ channel, value: JSON.parse(JSON.stringify(value)) });
|
||||||
|
}
|
||||||
|
isLoadingMainFrame() { return this.loading; }
|
||||||
|
setWindowOpenHandler() {}
|
||||||
|
getZoomFactor() { return 1; }
|
||||||
|
}
|
||||||
|
class FakeWindow extends EventEmitter {
|
||||||
|
webContents = new FakeWebContents();
|
||||||
|
minimized = false;
|
||||||
|
focused = false;
|
||||||
|
constructor() { super(); windows.push(this); }
|
||||||
|
loadURL() { this.webContents.loading = true; }
|
||||||
|
isMinimized() { return this.minimized; }
|
||||||
|
restore() { this.minimized = false; }
|
||||||
|
focus() { this.focused = true; }
|
||||||
|
show() {}
|
||||||
|
static getAllWindows() {
|
||||||
|
if (enumerationFails) throw new Error("window enumeration failed");
|
||||||
|
return windows;
|
||||||
|
}
|
||||||
|
static fromWebContents(contents: FakeWebContents) { return windows.find(win => win.webContents === contents); }
|
||||||
|
}
|
||||||
|
const app = Object.assign(new EventEmitter(), {
|
||||||
|
isPackaged: packaged,
|
||||||
|
name: "LearnHarness",
|
||||||
|
setName() {},
|
||||||
|
getPath: () => userData,
|
||||||
|
setPath: (_: string, value: string) => { userData = value; },
|
||||||
|
requestSingleInstanceLock: () => false,
|
||||||
|
quit() {},
|
||||||
|
whenReady: async () => {},
|
||||||
|
});
|
||||||
|
const electron = {
|
||||||
|
app, BrowserWindow: FakeWindow,
|
||||||
|
Menu: { buildFromTemplate: (value: typeof menu) => value, setApplicationMenu: (value: typeof menu) => { menu = value; } },
|
||||||
|
ipcMain: { on() {}, handle: (channel: string, fn: (event: unknown) => unknown) => handles.set(channel, fn) },
|
||||||
|
session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler() {} } },
|
||||||
|
};
|
||||||
|
const context = createContext({
|
||||||
|
require: (id: string) => {
|
||||||
|
if (id === "electron") return electron;
|
||||||
|
if (id === "electron-updater") {
|
||||||
|
if (updaterMissing) throw new Error("missing updater");
|
||||||
|
return { autoUpdater: updater };
|
||||||
|
}
|
||||||
|
if (id === "node:fs") return {
|
||||||
|
readFileSync: (file: string) => {
|
||||||
|
settingsReads.push(file);
|
||||||
|
if (settings instanceof Error) throw settings;
|
||||||
|
return settings;
|
||||||
|
}, mkdirSync() {}, appendFileSync() {},
|
||||||
|
};
|
||||||
|
return mainRequire(id);
|
||||||
|
},
|
||||||
|
process: { argv: [], env: { LH_DEV_URL: "http://127.0.0.1:9000" }, platform: "darwin", on() {} },
|
||||||
|
__dirname: path.dirname(mainFile), URL,
|
||||||
|
setInterval: (callback: () => void, delay: number) => intervals.push({ callback, delay }),
|
||||||
|
setTimeout() {},
|
||||||
|
fixtureChild: { kill: (signal: string) => order.push(signal) },
|
||||||
|
});
|
||||||
|
runInContext(mainSource, context, { filename: mainFile });
|
||||||
|
const run = (code: string) => runInContext(code, context);
|
||||||
|
return {
|
||||||
|
run, updater, windows, intervals, handles, settingsReads, order, app,
|
||||||
|
setSettings: (value: unknown) => { settings = JSON.stringify(value); },
|
||||||
|
setSettingsFailure: (value: string | Error) => { settings = value; },
|
||||||
|
setEnumerationFailure: (value: boolean) => { enumerationFails = value; },
|
||||||
|
window: () => new FakeWindow(),
|
||||||
|
state: () => JSON.parse(JSON.stringify(run("updateState"))),
|
||||||
|
check: async (manual = false) => { run(`checkForUpdates({ manual: ${manual} })`); await Promise.resolve(); await Promise.resolve(); },
|
||||||
|
menuCheck: () => menu[0]!.submenu!.find(item => item.label === "Check for Updates…")!.click!(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("automatic-update preference gate", () => {
|
||||||
|
it("explicit OFF skips automatic checks, but manual checks still run without reading the preference", async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.setSettings({ general: { autoUpdate: false } });
|
||||||
|
f.run("setupAutoUpdater()");
|
||||||
|
await f.check();
|
||||||
|
assert.equal(f.updater.checks, 0);
|
||||||
|
assert.deepEqual(f.settingsReads, ["/fixture/custom-user-data-dev/data/settings.json"]);
|
||||||
|
await f.check(true);
|
||||||
|
assert.equal(f.updater.checks, 1);
|
||||||
|
assert.equal(f.settingsReads.length, 1);
|
||||||
|
assert.equal(f.updater.autoDownload, true);
|
||||||
|
assert.equal(f.updater.autoInstallOnAppQuit, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [label, setting] of [
|
||||||
|
["ON", { general: { autoUpdate: true } }], ["absent field", { general: {} }],
|
||||||
|
["absent section", { version: 1 }], ["null", null], ["non-boolean", { general: { autoUpdate: "false" } }],
|
||||||
|
] as const) {
|
||||||
|
it(`${label} keeps automatic checks enabled`, async () => {
|
||||||
|
const f = fixture(); f.setSettings(setting); f.run("setupAutoUpdater()"); await f.check();
|
||||||
|
assert.equal(f.updater.checks, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const [label, failure] of [["missing", new Error("ENOENT")], ["unreadable", new Error("EACCES")], ["corrupt", "{nope"]] as const) {
|
||||||
|
it(`${label} settings keep automatic checks enabled`, async () => {
|
||||||
|
const f = fixture(); f.setSettingsFailure(failure); f.run("setupAutoUpdater()"); await f.check();
|
||||||
|
assert.equal(f.updater.checks, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("re-reads the setting on each check, without triggering a check merely when saved", async () => {
|
||||||
|
const f = fixture(); f.run("setupAutoUpdater()");
|
||||||
|
f.setSettings({ general: { autoUpdate: false } }); await f.check();
|
||||||
|
f.setSettings({ general: { autoUpdate: true } });
|
||||||
|
assert.equal(f.updater.checks, 0);
|
||||||
|
await f.check();
|
||||||
|
f.setSettings({ general: { autoUpdate: false } }); await f.check();
|
||||||
|
assert.equal(f.updater.checks, 1);
|
||||||
|
assert.equal(f.settingsReads.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const label of ["unpackaged", "missing updater"] as const) {
|
||||||
|
it(`${label} stays unavailable, not a saved OFF preference`, async () => {
|
||||||
|
const f = fixture({ packaged: label !== "unpackaged", updaterMissing: label === "missing updater" });
|
||||||
|
f.run("setupAutoUpdater()"); await f.check(true); await f.check();
|
||||||
|
assert.equal(f.updater.checks, 0);
|
||||||
|
assert.equal(f.settingsReads.length, 0);
|
||||||
|
assert.equal(f.state().status, "disabled");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("status observation never controls the updater", () => {
|
||||||
|
it("reports events, but a staged update stays visible without suppressing future checks", async () => {
|
||||||
|
const f = fixture(); const win = f.window(); f.run("setupAutoUpdater()");
|
||||||
|
f.updater.emit("checking-for-update"); assert.equal(f.state().status, "checking");
|
||||||
|
f.updater.emit("update-available", { version: "0.3.8" });
|
||||||
|
assert.equal(f.state().status, "downloading"); assert.equal(f.state().version, "0.3.8");
|
||||||
|
f.updater.emit("update-downloaded", { version: "0.3.8" });
|
||||||
|
const ready = f.state();
|
||||||
|
await f.check();
|
||||||
|
f.updater.emit("error", new Error("failure with sensitive diagnostic details"));
|
||||||
|
assert.equal(f.updater.checks, 1);
|
||||||
|
assert.deepEqual(f.state(), ready);
|
||||||
|
f.updater.emit("update-downloaded", { version: "0.3.9" });
|
||||||
|
assert.equal(f.state().version, "0.3.9");
|
||||||
|
assert.ok(f.state().revision > ready.revision);
|
||||||
|
assert.equal(win.webContents.messages.at(-1)!.channel, "lh:update-status");
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const status of ["checking", "downloading", "error", "disabled", "downloaded"]) {
|
||||||
|
it(`a ${status} display state does not block a future automatic check`, async () => {
|
||||||
|
const f = fixture(); f.run(`setupAutoUpdater(); reportUpdateStatus("${status}")`); await f.check();
|
||||||
|
assert.equal(f.updater.checks, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("check failures settle to a generic error and later checks still run", async () => {
|
||||||
|
const f = fixture(); f.run("setupAutoUpdater()");
|
||||||
|
f.updater.failure = new Error("private endpoint/diagnostic detail"); await f.check();
|
||||||
|
assert.equal(f.state().status, "error");
|
||||||
|
assert.ok(!JSON.stringify(f.state()).includes("private"));
|
||||||
|
f.updater.failure = null; await f.check();
|
||||||
|
assert.equal(f.updater.checks, 2); assert.equal(f.state().status, "up-to-date");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a declined check makes no up-to-date claim", async () => {
|
||||||
|
const f = fixture(); f.updater.result = null; f.run("setupAutoUpdater()"); await f.check(true);
|
||||||
|
assert.equal(f.state().status, "idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("window enumeration/send failures do not break checks or the next window's notification", async () => {
|
||||||
|
const f = fixture(); const bad = f.window(); bad.webContents.fails = true; const good = f.window();
|
||||||
|
f.run("setupAutoUpdater()"); await f.check();
|
||||||
|
assert.equal(good.webContents.messages.at(-1)!.channel, "lh:update-status");
|
||||||
|
f.setEnumerationFailure(true); await f.check();
|
||||||
|
f.setEnumerationFailure(false); await f.check();
|
||||||
|
assert.equal(f.updater.checks, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("OFF does not cancel in-flight events or alter staging/install-on-quit; Restart kills server first", async () => {
|
||||||
|
const f = fixture(); f.run("setupAutoUpdater()");
|
||||||
|
f.updater.emit("update-available", { version: "0.3.8" });
|
||||||
|
f.setSettings({ general: { autoUpdate: false } });
|
||||||
|
f.updater.emit("update-downloaded", { version: "0.3.8" }); await f.check();
|
||||||
|
assert.equal(f.state().status, "downloaded"); assert.equal(f.updater.checks, 0);
|
||||||
|
assert.equal(f.updater.autoDownload, true); assert.equal(f.updater.autoInstallOnAppQuit, true);
|
||||||
|
f.run("serverProcess = fixtureChild; installUpdateNow()");
|
||||||
|
assert.deepEqual(f.order, ["SIGTERM", "install"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("startup, timer, trusted IPC and menu wiring", () => {
|
||||||
|
it("starts and retains the 24h timer without any Settings subscription, checking current preference each time", async () => {
|
||||||
|
const f = fixture(); f.setSettings({ general: { autoUpdate: false } });
|
||||||
|
await f.run("main()");
|
||||||
|
assert.equal(f.updater.checks, 0);
|
||||||
|
assert.equal(f.intervals.length, 1); assert.equal(f.intervals[0]!.delay, 24 * 60 * 60 * 1000);
|
||||||
|
f.setSettings({ general: { autoUpdate: true } }); f.intervals[0]!.callback(); await Promise.resolve();
|
||||||
|
assert.equal(f.updater.checks, 1);
|
||||||
|
f.windows.length = 0; f.intervals[0]!.callback(); await Promise.resolve();
|
||||||
|
assert.equal(f.updater.checks, 2, "checks also work with every window closed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ON/default boot checks immediately and status never disables the scheduled check", async () => {
|
||||||
|
const f = fixture(); f.setSettings({ version: 1 }); await f.run("main()");
|
||||||
|
assert.equal(f.updater.checks, 1);
|
||||||
|
f.run('reportUpdateStatus("downloading", "0.3.8")'); f.intervals[0]!.callback(); await Promise.resolve();
|
||||||
|
assert.equal(f.updater.checks, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snapshot/manual IPC accepts only an owned main frame and snapshot reads never check", async () => {
|
||||||
|
const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()");
|
||||||
|
const sender = f.windows[0]!.webContents;
|
||||||
|
const event = { sender, senderFrame: sender.mainFrame };
|
||||||
|
const get = f.handles.get("lh:update:get")!; const check = f.handles.get("lh:update:check")!;
|
||||||
|
assert.equal((get(event) as { status: string }).status, "idle"); assert.equal(f.updater.checks, 0);
|
||||||
|
check(event); assert.equal(f.updater.checks, 1);
|
||||||
|
for (const bad of [
|
||||||
|
{ sender: {}, senderFrame: sender.mainFrame },
|
||||||
|
{ sender, senderFrame: { url: sender.mainFrame.url } },
|
||||||
|
]) {
|
||||||
|
assert.throws(() => get(bad), /Untrusted/); assert.throws(() => check(bad), /Untrusted/);
|
||||||
|
}
|
||||||
|
sender.mainFrame.url = "https://untrusted.invalid/";
|
||||||
|
assert.throws(() => check(event), /Untrusted/);
|
||||||
|
assert.equal(f.updater.checks, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("menu restores/focuses a minimized window, opens General, and manually checks while OFF", async () => {
|
||||||
|
const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()");
|
||||||
|
const win = f.windows[0]!; win.webContents.loading = false; win.minimized = true;
|
||||||
|
f.menuCheck();
|
||||||
|
assert.equal(win.minimized, false); assert.equal(win.focused, true);
|
||||||
|
assert.ok(win.webContents.messages.some(message => message.channel === "lh:open-settings" && message.value === "general"));
|
||||||
|
assert.equal(f.updater.checks, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("menu reopens a window and delivers General after load; loading existing windows are covered too", async () => {
|
||||||
|
const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()");
|
||||||
|
f.windows.length = 0; f.menuCheck();
|
||||||
|
assert.equal(f.windows.length, 1); assert.equal(f.updater.checks, 1);
|
||||||
|
const win = f.windows[0]!;
|
||||||
|
assert.ok(!win.webContents.messages.some(message => message.channel === "lh:open-settings"));
|
||||||
|
win.webContents.emit("did-finish-load");
|
||||||
|
assert.ok(win.webContents.messages.some(message => message.channel === "lh:open-settings"));
|
||||||
|
f.menuCheck(); win.webContents.emit("did-finish-load");
|
||||||
|
assert.equal(win.webContents.messages.filter(message => message.channel === "lh:open-settings").length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("menu navigation failure does not prevent its manual check", async () => {
|
||||||
|
const f = fixture(); f.setSettings({ general: { autoUpdate: false } }); await f.run("main()");
|
||||||
|
f.windows[0]!.webContents.fails = true; f.windows[0]!.webContents.loading = false;
|
||||||
|
f.menuCheck(); assert.equal(f.updater.checks, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -123,9 +123,9 @@ describe("prefillFromSlots — the SetupDialog prefill (BYOK wave)", () => {
|
|||||||
assert.deepEqual(pre, {});
|
assert.deepEqual(pre, {});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a pin whose model left the federated catalog does not prefill (lapsed key)", () => {
|
it("a disconnected saved identity still prefills without selectable metadata", () => {
|
||||||
const pre = prefillFromSlots(catalog, { strong: { model: { provider: "gone", id: "vanished" } }, cheap: null, vision: null });
|
const pre = prefillFromSlots(catalog, { strong: { model: { provider: "gone", id: "vanished" } }, cheap: null, vision: null });
|
||||||
assert.deepEqual(pre, {});
|
assert.deepEqual(pre, { strong: { provider: "gone", id: "vanished" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("provider collisions never cross-pollute (same id, different provider)", () => {
|
it("provider collisions never cross-pollute (same id, different provider)", () => {
|
||||||
@@ -158,9 +158,9 @@ describe("prefillLevelFromPin — the setup dialog's level prefill (chunk 2)", (
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("setupStage — unchanged by the BYOK wave", () => {
|
describe("setupStage — unknown is not keyless", () => {
|
||||||
it("keys first (owner ordering), pickers when a provider is keyed", () => {
|
it("keys first (owner ordering), pickers when a provider is keyed", () => {
|
||||||
assert.equal(setupStage(null), "keys");
|
assert.equal(setupStage(null), "loading");
|
||||||
assert.equal(setupStage([{ id: "x", keyPresent: false }]), "keys");
|
assert.equal(setupStage([{ id: "x", keyPresent: false }]), "keys");
|
||||||
assert.equal(setupStage([{ id: "x", keyPresent: true }]), "pickers");
|
assert.equal(setupStage([{ id: "x", keyPresent: true }]), "pickers");
|
||||||
});
|
});
|
||||||
|
|||||||
+104
-4
@@ -30,13 +30,14 @@ import { createTeacherTools } from "../src/agent/tools.ts";
|
|||||||
import { handleChatInput, handleChatRequest } from "../src/server/chat-handler.ts";
|
import { handleChatInput, handleChatRequest } from "../src/server/chat-handler.ts";
|
||||||
import { effectiveSettings } from "../src/server/stores.ts";
|
import { effectiveSettings } from "../src/server/stores.ts";
|
||||||
import { isTurnRunning } from "../src/server/turn-liveness.ts";
|
import { isTurnRunning } from "../src/server/turn-liveness.ts";
|
||||||
import { deliverJobResult, setTurnRunner } from "../src/server/jobs.ts";
|
import { deliverJobResult, registerLiveAgent, setTurnRunner, steerLiveChat, unregisterLiveAgent } from "../src/server/jobs.ts";
|
||||||
|
import { SessionModel } from "../components/session-core.ts";
|
||||||
import { buildSubagentSteer } from "../src/core/contracts.ts";
|
import { buildSubagentSteer } from "../src/core/contracts.ts";
|
||||||
import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts";
|
import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts";
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
import { chatStore } from "../src/store/chats.ts";
|
||||||
import { jobStore } from "../src/store/jobs.ts";
|
import { jobStore } from "../src/store/jobs.ts";
|
||||||
import { buildAnswer, dismissedAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
|
import { buildAnswer, dismissedAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
|
||||||
import { danglingQuestionOf, endsInDismissal, needsContinuation, newAnswerResult } from "../src/core/native-messages.ts";
|
import { danglingQuestionOf, endsInDismissal, needsContinuation, newAnswerResult, newUserMessage } from "../src/core/native-messages.ts";
|
||||||
import { projectView } from "../src/core/view.ts";
|
import { projectView } from "../src/core/view.ts";
|
||||||
import type { PiEngine } from "../src/agent/models.ts";
|
import type { PiEngine } from "../src/agent/models.ts";
|
||||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
@@ -46,6 +47,8 @@ import type { LhMessage } from "../src/core/native-messages.ts";
|
|||||||
// store imports already resolved under).
|
// store imports already resolved under).
|
||||||
const dismissRoute = await import("../app/api/chats/[id]/dismiss/route.ts");
|
const dismissRoute = await import("../app/api/chats/[id]/dismiss/route.ts");
|
||||||
const steerRoute = await import("../app/api/chats/[id]/steer/route.ts");
|
const steerRoute = await import("../app/api/chats/[id]/steer/route.ts");
|
||||||
|
const streamRoute = await import("../app/api/chats/[id]/stream/route.ts");
|
||||||
|
const recordRoute = await import("../app/api/chats/[id]/route.ts");
|
||||||
import { PATHS } from "../config.ts";
|
import { PATHS } from "../config.ts";
|
||||||
|
|
||||||
// The key-presence turn gate (provider-key interlude, 2026-09-25): scripted
|
// The key-presence turn gate (provider-key interlude, 2026-09-25): scripted
|
||||||
@@ -519,7 +522,7 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-mid" });
|
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-mid" });
|
||||||
jobIdMid = job.id;
|
jobIdMid = job.id;
|
||||||
// The runner stores the result BEFORE delivery (finalizeJob reads it).
|
// The runner stores the result BEFORE delivery (finalizeJob reads it).
|
||||||
jobStore.updateJob(job.id, { result: { brief: "Mid-run brief.", sources: [] } });
|
jobStore.updateJob(job.id, { status: "done", result: { brief: "Mid-run brief.", sources: [] }, delivery: { kind: "result", status: "pending" } });
|
||||||
const steer = buildSubagentSteer(job, { brief: "Mid-run brief.", sources: [] });
|
const steer = buildSubagentSteer(job, { brief: "Mid-run brief.", sources: [] });
|
||||||
const delivery = deliverJobResult(steer);
|
const delivery = deliverJobResult(steer);
|
||||||
await new Promise((r) => setTimeout(r, 300)); // vehicle 1 hands off
|
await new Promise((r) => setTimeout(r, 300)); // vehicle 1 hands off
|
||||||
@@ -531,7 +534,7 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
release2();
|
release2();
|
||||||
await drain(await res);
|
await drain(await res);
|
||||||
await delivery; // resolves at settleSteers (the run persisted the marker)
|
await delivery; // resolves at settleSteers (the run persisted the marker)
|
||||||
assert.equal(jobFlippedMidTurn, "done", "the job flipped at the marker's message_end — WHILE the turn still streamed");
|
assert.equal(jobFlippedMidTurn, "done", "worker completion is independent of the streaming teacher turn");
|
||||||
|
|
||||||
// Record shape: the marker sits BETWEEN completed turn units — after
|
// Record shape: the marker sits BETWEEN completed turn units — after
|
||||||
// the toolResult it must never split, adjacent to it.
|
// the toolResult it must never split, adjacent to it.
|
||||||
@@ -845,6 +848,103 @@ describe("chat store", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("canonical persistence failures", () => {
|
||||||
|
it("refuses a failed input save without building an agent or leaving a live turn", async () => {
|
||||||
|
const { engine } = scriptedTeacher(defaultLesson);
|
||||||
|
const chat = createConfiguredChat("Test: failed answer write-ahead");
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
await drainTurn(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine));
|
||||||
|
const before = chatStore.getChat(chat.id)!.messages;
|
||||||
|
const save = chatStore.saveMessages;
|
||||||
|
let builds = 0;
|
||||||
|
chatStore.saveMessages = () => { throw new Error("synthetic write-ahead failure"); };
|
||||||
|
try {
|
||||||
|
const res = await handleChatRequest(post(chat.id, { type: "answer", toolCallId: "call-1", answer: gradedAnswer(QUIZ_INPUT, ["Mercury"]) }), (opts) => {
|
||||||
|
builds++;
|
||||||
|
return agentFactory(engine)!(opts);
|
||||||
|
});
|
||||||
|
const running = runningTurn(chat.id);
|
||||||
|
if (running) await new Response(running.subscribe()).text();
|
||||||
|
assert.equal(res.status, 500);
|
||||||
|
const client = new SessionModel({ chatId: chat.id, initialMessages: before, fetch: async () => res.clone() });
|
||||||
|
await client.submitAnswer("quiz", "call-1", gradedAnswer(QUIZ_INPUT, ["Mercury"]));
|
||||||
|
assert.equal(client.getState().error?.message, "Your input could not be saved. Please try again.");
|
||||||
|
assert.equal(await res.text(), "Your input could not be saved. Please try again.");
|
||||||
|
assert.equal(builds, 0);
|
||||||
|
assert.equal(isTurnRunning(chat.id), false);
|
||||||
|
assert.equal(runningTurn(chat.id), undefined);
|
||||||
|
assert.deepEqual(chatStore.getChat(chat.id)!.messages, before);
|
||||||
|
} finally { chatStore.saveMessages = save; }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a failed reply save before DONE while keeping the accepted answer durable", async () => {
|
||||||
|
const { engine } = scriptedTeacher(defaultLesson);
|
||||||
|
const chat = createConfiguredChat("Test: failed reply persistence");
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
await drainTurn(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine));
|
||||||
|
const save = chatStore.saveMessages;
|
||||||
|
let writes = 0;
|
||||||
|
chatStore.saveMessages = (id, messages) => {
|
||||||
|
if (++writes === 2) throw new Error("synthetic reply save failure");
|
||||||
|
return save(id, messages);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const body = await drainTurn(post(chat.id, { type: "answer", toolCallId: "call-1", answer: gradedAnswer(QUIZ_INPUT, ["Mercury"]) }), agentFactory(engine));
|
||||||
|
const errors = eventsOf(body).filter(e => e.type === "error");
|
||||||
|
assert.equal(errors.length, 1);
|
||||||
|
assert.equal(errors[0]!.errorText, "The reply could not be saved.");
|
||||||
|
assert.ok(body.indexOf("The reply could not be saved.") < body.indexOf("[DONE]"));
|
||||||
|
assert.equal(isTurnRunning(chat.id), false);
|
||||||
|
assert.equal(runningTurn(chat.id), undefined);
|
||||||
|
assert.equal(chatStore.getChat(chat.id)!.messages.at(-1)?.role, "toolResult");
|
||||||
|
// The normal submitter may not reach GET /stream until AFTER the
|
||||||
|
// failing turn has left the live registry. It must still see it.
|
||||||
|
const late = await streamRoute.GET(new Request("http://test/stream"), { params: Promise.resolve({ id: chat.id }) });
|
||||||
|
assert.equal(eventsOf(await late.text()).find(e => e.type === "error")?.errorText, "The reply could not be saved.");
|
||||||
|
// An already-open idle view can miss both the start and the wire
|
||||||
|
// error, then learn of settlement only via a record synchronization.
|
||||||
|
const snapshot = await (await recordRoute.GET(new Request("http://test/record"), { params: Promise.resolve({ id: chat.id }) })).json();
|
||||||
|
assert.equal(needsContinuation(snapshot.messages), true, "the durable answer alone looks like an owed continuation");
|
||||||
|
let automaticPosts = 0;
|
||||||
|
const missedView = new SessionModel({ chatId: chat.id, initialMessages: [], fetch: async () => {
|
||||||
|
automaticPosts++;
|
||||||
|
return new Response("unexpected automatic retry", { status: 500 });
|
||||||
|
} });
|
||||||
|
missedView.setMessages(snapshot.messages, snapshot.turnError);
|
||||||
|
await missedView.continueIfOwed(snapshot.messages);
|
||||||
|
assert.equal(automaticPosts, 0, "the record notice must block replacement even without a stream attachment");
|
||||||
|
assert.equal(missedView.getState().error?.message, "The reply could not be saved.");
|
||||||
|
} finally { chatStore.saveMessages = save; }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not redeliver a persisted learner steer after usage bookkeeping fails", async () => {
|
||||||
|
const { engine } = scriptedTeacher(() => [() => fauxAssistantMessage([fauxText("Synthetic response")])]);
|
||||||
|
const chat = createConfiguredChat("Test: post-save bookkeeping failure");
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
const steer = newUserMessage("Synthetic learner steer");
|
||||||
|
const appendUsage = chatStore.appendTurnUsage;
|
||||||
|
chatStore.appendTurnUsage = () => { throw new Error("synthetic usage write failure"); };
|
||||||
|
try {
|
||||||
|
const body = await drainTurn(post(chat.id, { type: "text", text: "go" }), opts => {
|
||||||
|
const agent = createTeacherAgent(undefined, { engine, messages: opts.messages, onUsage: opts.onUsage });
|
||||||
|
agent.subscribe(event => {
|
||||||
|
if (event.type === "agent_start") assert.equal(steerLiveChat(chat.id, steer), true);
|
||||||
|
});
|
||||||
|
return agent;
|
||||||
|
});
|
||||||
|
assert.equal(eventsOf(body).find(e => e.type === "error")?.errorText, "An error occurred.");
|
||||||
|
assert.ok(chatStore.getChat(chat.id)!.messages.some(m => m.id === steer.id), "the steer was saved before bookkeeping failed");
|
||||||
|
const delivered: LhMessage[] = [];
|
||||||
|
const next = { steer: (message: LhMessage) => delivered.push(message), state: { model: { provider: "faux" } } } as unknown as Parameters<typeof registerLiveAgent>[1];
|
||||||
|
registerLiveAgent(chat.id, next);
|
||||||
|
assert.deepEqual(delivered, [], "an already-saved learner steer must not reach another agent");
|
||||||
|
} finally {
|
||||||
|
chatStore.appendTurnUsage = appendUsage;
|
||||||
|
unregisterLiveAgent(chat.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("mid-turn navigation (turn liveness + write-ahead)", () => {
|
describe("mid-turn navigation (turn liveness + write-ahead)", () => {
|
||||||
it("one turn per chat: a concurrent request is 409'd; the next one after finish is accepted", async () => {
|
it("one turn per chat: a concurrent request is 409'd; the next one after finish is accepted", async () => {
|
||||||
const { engine } = scriptedTeacher(defaultLesson);
|
const { engine } = scriptedTeacher(defaultLesson);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ for (const revokedAfterQueue of [false, true]) {
|
|||||||
stores.chatStore.saveSettings(chat.id, { model: { provider: "chatgpt", id: "gpt-5" } });
|
stores.chatStore.saveSettings(chat.id, { model: { provider: "chatgpt", id: "gpt-5" } });
|
||||||
const job = stores.jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "scratch-job-call" });
|
const job = stores.jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "scratch-job-call" });
|
||||||
const result = { brief: "Scratch completed job result", sources: [] };
|
const result = { brief: "Scratch completed job result", sources: [] };
|
||||||
stores.jobStore.updateJob(job.id, { status: "running", result });
|
stores.jobStore.updateJob(job.id, { status: "done", result, delivery: { kind: "result", status: "pending" } });
|
||||||
const marker = buildSubagentSteer(job, result);
|
const marker = buildSubagentSteer(job, result);
|
||||||
const contexts: string[] = [];
|
const contexts: string[] = [];
|
||||||
const native = piModels().getModel("openai", "gpt-5")!;
|
const native = piModels().getModel("openai", "gpt-5")!;
|
||||||
@@ -83,7 +83,8 @@ for (const revokedAfterQueue of [false, true]) {
|
|||||||
unregisterLiveAgent(chat.id); endTurn(chat.id); settleSteers(chat.id, persisted);
|
unregisterLiveAgent(chat.id); endTurn(chat.id); settleSteers(chat.id, persisted);
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
assert.equal(wakes, 0, "no background spin or spend while consent is absent");
|
assert.equal(wakes, 0, "no background spin or spend while consent is absent");
|
||||||
assert.equal(stores.jobStore.getJob(job.id)!.status, "running", "delivery ownership must not be finalized or failed by deferral");
|
assert.equal(stores.jobStore.getJob(job.id)!.delivery?.status, "pending", "delivery ownership must not be finalized or failed by deferral");
|
||||||
|
assert.equal(stores.jobStore.getJob(job.id)!.status, "done", "worker completion is independent of consent deferral");
|
||||||
assert.deepEqual(stores.jobStore.getJob(job.id)!.result, result);
|
assert.deepEqual(stores.jobStore.getJob(job.id)!.result, result);
|
||||||
connection(true);
|
connection(true);
|
||||||
await delivered;
|
await delivered;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const { GET } = await import("../app/api/models/route.ts");
|
|||||||
const { chatGPTRequest, chatGPTConnected, chatGPTModels } = await import("../src/agent/chatgpt-bridge.ts");
|
const { chatGPTRequest, chatGPTConnected, chatGPTModels } = await import("../src/agent/chatgpt-bridge.ts");
|
||||||
after(() => fs.rmSync(scratch, { recursive: true, force: true }));
|
after(() => fs.rmSync(scratch, { recursive: true, force: true }));
|
||||||
|
|
||||||
test("Next-style connected IPC is not desktop transport and never delays keyed API catalogs", async () => {
|
test("Next-style IPC is not desktop transport; web ownership works without delaying keyed catalogs", async () => {
|
||||||
const send = Object.getOwnPropertyDescriptor(process, "send");
|
const send = Object.getOwnPropertyDescriptor(process, "send");
|
||||||
const connected = Object.getOwnPropertyDescriptor(process, "connected");
|
const connected = Object.getOwnPropertyDescriptor(process, "connected");
|
||||||
const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET;
|
const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET;
|
||||||
@@ -35,7 +35,11 @@ test("Next-style connected IPC is not desktop transport and never delays keyed A
|
|||||||
assert.equal(catalog.providers.find((p: { id: string }) => p.id === "chatgpt").keyPresent, false);
|
assert.equal(catalog.providers.find((p: { id: string }) => p.id === "chatgpt").keyPresent, false);
|
||||||
assert.equal(chatGPTConnected(), false);
|
assert.equal(chatGPTConnected(), false);
|
||||||
assert.deepEqual(chatGPTModels(), []);
|
assert.deepEqual(chatGPTModels(), []);
|
||||||
await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/);
|
if (!mode && !capability) {
|
||||||
|
const value = await chatGPTRequest<{ status: { available: boolean; accounts: unknown[] } }>("status");
|
||||||
|
assert.equal(value.status.available, true);
|
||||||
|
assert.deepEqual(value.status.accounts, []);
|
||||||
|
} else await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/);
|
||||||
assert.equal(sends, 0, "no desktop request/timer may be started over framework IPC");
|
assert.equal(sends, 0, "no desktop request/timer may be started over framework IPC");
|
||||||
}
|
}
|
||||||
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
|
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
|
||||||
|
|||||||
@@ -27,12 +27,14 @@ const {
|
|||||||
ensureCompacted,
|
ensureCompacted,
|
||||||
kickCompaction,
|
kickCompaction,
|
||||||
awaitCompaction,
|
awaitCompaction,
|
||||||
|
compactionInFlight,
|
||||||
effectiveThresholdTokens,
|
effectiveThresholdTokens,
|
||||||
projectedContextTokens,
|
projectedContextTokens,
|
||||||
} = await import("../src/server/compaction.ts");
|
} = await import("../src/server/compaction.ts");
|
||||||
import type { CompactionDeps } from "../src/server/compaction.ts";
|
import type { CompactionDeps } from "../src/server/compaction.ts";
|
||||||
|
import { createFauxCore, fauxAssistantMessage } from "@earendil-works/pi-ai";
|
||||||
const { subscribe } = await import("../src/core/events.ts");
|
const { subscribe } = await import("../src/core/events.ts");
|
||||||
const { activeCompactionOf, newUserMessage, measuredContextTokens, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts");
|
const { activeCompactionOf, newCompactionMarker, newUserMessage, measuredContextTokens, toLlmMessages, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts");
|
||||||
import type { LhMessage } from "../src/core/index.ts";
|
import type { LhMessage } from "../src/core/index.ts";
|
||||||
|
|
||||||
after(() => {
|
after(() => {
|
||||||
@@ -167,12 +169,120 @@ describe("the compaction service", () => {
|
|||||||
await ensureCompacted(deps, chatId);
|
await ensureCompacted(deps, chatId);
|
||||||
assert.ok(seenPrompt.includes("prior compaction summary"), "the prior summary rides the span");
|
assert.ok(seenPrompt.includes("prior compaction summary"), "the prior summary rides the span");
|
||||||
assert.ok(seenPrompt.includes(COMPACTED_HISTORY_HEADER), "as the marker's canonical text");
|
assert.ok(seenPrompt.includes(COMPACTED_HISTORY_HEADER), "as the marker's canonical text");
|
||||||
|
assert.equal(seenPrompt.split(COMPACTED_HISTORY_HEADER).length - 1, 1, "the prior summary is supplied exactly once");
|
||||||
const record = chatStore.getChat(chatId)!;
|
const record = chatStore.getChat(chatId)!;
|
||||||
const markers = record.messages.filter((m) => (m as { customType?: string }).customType === "compaction");
|
const markers = record.messages.filter((m) => (m as { customType?: string }).customType === "compaction");
|
||||||
assert.equal(markers.length, 2, "both markers persist (history is never rewritten)");
|
assert.equal(markers.length, 2, "both markers persist (history is never rewritten)");
|
||||||
assert.ok(activeCompactionOf(record.messages)!.content.includes("SECOND SUMMARY"), "the newest rules");
|
assert.ok(activeCompactionOf(record.messages)!.content.includes("SECOND SUMMARY"), "the newest rules");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ten successive compactions keep one summary on the wire and preserve the complete record", async () => {
|
||||||
|
const chat = chatStore.createChat("repeated-cycle fixture");
|
||||||
|
chatStore.saveMessages(chat.id, Array.from({ length: 20 }, (_, i) => big(`exchange-${i}`, 8)));
|
||||||
|
let calls = 0;
|
||||||
|
const deps: CompactionDeps = {
|
||||||
|
...depsOf(chat.id, 150_000),
|
||||||
|
summarize: async (prompt) => {
|
||||||
|
assert.equal(prompt.split(COMPACTED_HISTORY_HEADER).length - 1, calls === 0 ? 0 : 1);
|
||||||
|
if (calls > 0) assert.ok(prompt.includes(`SUMMARY-${calls}.`), "only the immediately prior summary rides forward");
|
||||||
|
return {
|
||||||
|
summary: `SUMMARY-${++calls}.`,
|
||||||
|
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (let cycle = 1; cycle <= 10; cycle++) {
|
||||||
|
const messages = [...chatStore.getChat(chat.id)!.messages];
|
||||||
|
if (cycle > 1) messages.push(big(`growth-${cycle}`, 8));
|
||||||
|
// Controlled provider readings trigger compaction while the visible
|
||||||
|
// retained tail advances slowly enough to contain older markers.
|
||||||
|
messages.push({ id: `usage-${cycle}`, role: "assistant", content: [{ type: "text", text: "measured response" }], stopReason: "stop", usage: { input: 150_001, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 150_002 }, timestamp: cycle } as LhMessage);
|
||||||
|
chatStore.saveMessages(chat.id, messages);
|
||||||
|
await ensureCompacted(deps, chat.id);
|
||||||
|
assert.equal(calls, cycle);
|
||||||
|
const record = chatStore.getChat(chat.id)!;
|
||||||
|
assert.deepEqual(record.messages.slice(0, -1), messages, "every original message and marker remains stored");
|
||||||
|
assert.equal(record.messages.filter((m) => m.role === "custom" && m.customType === "compaction").length, cycle);
|
||||||
|
const wire = JSON.stringify(toLlmMessages(record.messages));
|
||||||
|
assert.equal(wire.split(COMPACTED_HISTORY_HEADER).length - 1, 1, `cycle ${cycle}: one summary on the wire`);
|
||||||
|
assert.ok(wire.includes(`SUMMARY-${cycle}.`));
|
||||||
|
assert.equal(measuredContextTokens(record.messages), null, "committing a marker invalidates pre-compaction readings");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a resolved prior summary replaces all summary markers in the newly superseded span", async () => {
|
||||||
|
const chat = chatStore.createChat("summary span fixture");
|
||||||
|
const kept = big("previously retained");
|
||||||
|
const details = { firstKeptMessageId: kept.id, tokensBefore: 150_000, at: "fixture" };
|
||||||
|
const steer: LhMessage = { id: "steer", role: "custom", customType: "subagent-steer", content: "RESEARCH DELIVERY", display: true, details: {}, timestamp: 1 };
|
||||||
|
const messages = [big("discarded"), kept, newCompactionMarker("SUPERSEDED SUMMARY", details), steer, newCompactionMarker("LATEST SUMMARY", details), big("growth"), big("recent")];
|
||||||
|
chatStore.saveMessages(chat.id, messages);
|
||||||
|
let seenPrompt = "";
|
||||||
|
await ensureCompacted({ ...depsOf(chat.id, 40_000), summarize: async (prompt, engine) => {
|
||||||
|
seenPrompt = prompt;
|
||||||
|
return depsOf(chat.id, 40_000).summarize!(prompt, engine);
|
||||||
|
} }, chat.id);
|
||||||
|
assert.ok(seenPrompt.includes("previously retained"));
|
||||||
|
assert.ok(seenPrompt.includes("RESEARCH DELIVERY"), "non-compaction context markers still ride the span");
|
||||||
|
assert.ok(!seenPrompt.includes("SUPERSEDED SUMMARY"));
|
||||||
|
assert.equal(seenPrompt.split("LATEST SUMMARY").length - 1, 1);
|
||||||
|
assert.equal(seenPrompt.split(COMPACTED_HISTORY_HEADER).length - 1, 1);
|
||||||
|
assert.deepEqual(chatStore.getChat(chat.id)!.messages.slice(0, -1), messages);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unresolved cutoff preserves legacy summary text in the next compaction input", async () => {
|
||||||
|
const chat = chatStore.createChat("legacy summary fixture");
|
||||||
|
const legacy = newCompactionMarker("LEGACY-ONLY INFORMATION", { firstKeptMessageId: "missing", tokensBefore: 150_000, at: "fixture" });
|
||||||
|
const messages = [big("old"), legacy, big("recent"), newUserMessage("latest")];
|
||||||
|
chatStore.saveMessages(chat.id, messages);
|
||||||
|
let seenPrompt = "";
|
||||||
|
await ensureCompacted({ ...depsOf(chat.id, 40_000), summarize: async (prompt, engine) => {
|
||||||
|
seenPrompt = prompt;
|
||||||
|
return depsOf(chat.id, 40_000).summarize!(prompt, engine);
|
||||||
|
} }, chat.id);
|
||||||
|
assert.equal(seenPrompt.split("LEGACY-ONLY INFORMATION").length - 1, 1);
|
||||||
|
assert.ok(!seenPrompt.includes("prior compaction summary"), "no separate prior summary exists for an unresolved cutoff");
|
||||||
|
assert.deepEqual(chatStore.getChat(chat.id)!.messages.slice(0, -1), messages);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the production summarizer rejects a length-stopped checkpoint and a complete retry can commit", async () => {
|
||||||
|
const chatId = overThresholdChat("incomplete summary fixture");
|
||||||
|
await ensureCompacted(depsOf(chatId, 40_000), chatId);
|
||||||
|
chatStore.saveMessages(chatId, [...chatStore.getChat(chatId)!.messages, big("growth 1"), big("growth 2")]);
|
||||||
|
const faux = createFauxCore({});
|
||||||
|
const { summarize: _injected, ...base } = depsOf(chatId, 40_000);
|
||||||
|
const deps: CompactionDeps = { ...base, engine: { model: faux.getModel(), streamFn: faux.streamSimple } };
|
||||||
|
const before = chatStore.getChat(chatId)!;
|
||||||
|
assert.ok(activeCompactionOf(before.messages), "the failed replacement must preserve an existing checkpoint too");
|
||||||
|
const events: string[] = [];
|
||||||
|
const off = subscribe((event) => {
|
||||||
|
const { chatId: id, running } = event as { chatId?: string; running?: boolean };
|
||||||
|
if (event.type === "compaction-changed" && id === chatId) events.push(`${running}`);
|
||||||
|
});
|
||||||
|
// Exercise the actual zero-tool summarizer with an offline provider;
|
||||||
|
// NODE_ENV=test otherwise deliberately skips non-injected summaries.
|
||||||
|
const env: Record<string, string | undefined> = process.env;
|
||||||
|
const originalNodeEnv = env.NODE_ENV;
|
||||||
|
env.NODE_ENV = "development";
|
||||||
|
try {
|
||||||
|
faux.setResponses([fauxAssistantMessage("INCOMPLETE SUMMARY", { stopReason: "length" })]);
|
||||||
|
await ensureCompacted(deps, chatId);
|
||||||
|
assert.deepEqual(chatStore.getChat(chatId), before, "an explicitly unfinished summary must not become a checkpoint");
|
||||||
|
assert.equal(compactionInFlight(chatId), false, "the failed run releases its gate");
|
||||||
|
assert.deepEqual(events, ["true", "false"]);
|
||||||
|
faux.setResponses([fauxAssistantMessage("COMPLETE SUMMARY")]);
|
||||||
|
await ensureCompacted(deps, chatId);
|
||||||
|
const record = chatStore.getChat(chatId)!;
|
||||||
|
assert.ok(activeCompactionOf(record.messages)!.content.includes("COMPLETE SUMMARY"));
|
||||||
|
assert.deepEqual(record.messages.slice(0, -1), before.messages);
|
||||||
|
assert.ok(!JSON.stringify(record.messages).includes("INCOMPLETE SUMMARY"));
|
||||||
|
} finally {
|
||||||
|
if (originalNodeEnv === undefined) delete env.NODE_ENV;
|
||||||
|
else env.NODE_ENV = originalNodeEnv;
|
||||||
|
off();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("effectiveThresholdTokens: the setting rules big windows; the window rule binds small ones", () => {
|
it("effectiveThresholdTokens: the setting rules big windows; the window rule binds small ones", () => {
|
||||||
assert.equal(effectiveThresholdTokens(150_000, 200_000), 150_000);
|
assert.equal(effectiveThresholdTokens(150_000, 200_000), 150_000);
|
||||||
assert.equal(effectiveThresholdTokens(150_000, 100_000), 100_000 - 16_384);
|
assert.equal(effectiveThresholdTokens(150_000, 100_000), 100_000 - 16_384);
|
||||||
|
|||||||
@@ -256,6 +256,22 @@ describe("toLlmMessages honors the cut", () => {
|
|||||||
assert.equal(JSON.stringify(out).indexOf("COMPACTED]"), JSON.stringify(out).lastIndexOf("COMPACTED]"), "header appears exactly once");
|
assert.equal(JSON.stringify(out).indexOf("COMPACTED]"), JSON.stringify(out).lastIndexOf("COMPACTED]"), "header appears exactly once");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("omits nine superseded summaries inside the retained tail without changing stored history", () => {
|
||||||
|
const kept = newUserMessage("retained exchange");
|
||||||
|
const details = { firstKeptMessageId: kept.id, tokensBefore: 200_000, at: "fixture" };
|
||||||
|
const old = Array.from({ length: 9 }, (_, i) => newCompactionMarker(`SUPERSEDED-${i}`, details));
|
||||||
|
const fresh = newCompactionMarker("LATEST SUMMARY", details);
|
||||||
|
const messages = [newUserMessage("discarded"), kept, ...old, newUserMessage("latest reply"), fresh];
|
||||||
|
const original = JSON.stringify(messages);
|
||||||
|
const out = toLlmMessages(messages);
|
||||||
|
const flat = JSON.stringify(out);
|
||||||
|
assert.equal(out.length, 3, "latest summary + retained exchange + latest reply");
|
||||||
|
assert.ok(flat.includes("LATEST SUMMARY"));
|
||||||
|
assert.ok(!flat.includes("SUPERSEDED-"));
|
||||||
|
assert.equal(flat.split(COMPACTED_HISTORY_HEADER).length - 1, 1);
|
||||||
|
assert.equal(JSON.stringify(messages), original, "projection never rewrites the record");
|
||||||
|
});
|
||||||
|
|
||||||
it("a pending (dangling) question call in the KEPT tail still gets the interrupted synthesis", () => {
|
it("a pending (dangling) question call in the KEPT tail still gets the interrupted synthesis", () => {
|
||||||
const kept = [assistantWithCall("a1", "call_1")];
|
const kept = [assistantWithCall("a1", "call_1")];
|
||||||
const marker = newCompactionMarker("S", { firstKeptMessageId: "a1", tokensBefore: 1, at: "t" });
|
const marker = newCompactionMarker("S", { firstKeptMessageId: "a1", tokensBefore: 1, at: "t" });
|
||||||
@@ -270,6 +286,22 @@ describe("toLlmMessages honors the cut", () => {
|
|||||||
assert.equal(out.length, 3, "no head swap — all three render (marker as its user text)");
|
assert.equal(out.length, 3, "no head swap — all three render (marker as its user text)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("an unresolved newest cutoff preserves the full-history fallback, including summary text", () => {
|
||||||
|
const kept = newUserMessage("retained exchange");
|
||||||
|
const old = newCompactionMarker("OLD SUMMARY", { firstKeptMessageId: kept.id, tokensBefore: 1, at: "fixture" });
|
||||||
|
const fresh = newCompactionMarker("LATEST SUMMARY", { firstKeptMessageId: "missing", tokensBefore: 2, at: "fixture" });
|
||||||
|
const messages = [newUserMessage("earliest exchange"), kept, old, newUserMessage("latest reply"), fresh];
|
||||||
|
const original = JSON.stringify(messages);
|
||||||
|
const out = toLlmMessages(messages);
|
||||||
|
assert.equal(out.length, messages.length);
|
||||||
|
assert.equal(out[0]!.content, "earliest exchange", "do not silently apply the older cutoff");
|
||||||
|
const flat = JSON.stringify(out);
|
||||||
|
assert.ok(flat.includes("OLD SUMMARY"));
|
||||||
|
assert.ok(flat.includes("LATEST SUMMARY"));
|
||||||
|
assert.equal(flat.split(COMPACTED_HISTORY_HEADER).length - 1, 2);
|
||||||
|
assert.equal(JSON.stringify(messages), original);
|
||||||
|
});
|
||||||
|
|
||||||
it("chained compaction: the newest marker's cut wins; the older marker falls in the dropped span", () => {
|
it("chained compaction: the newest marker's cut wins; the older marker falls in the dropped span", () => {
|
||||||
const oldMarker = newCompactionMarker("OLD", { firstKeptMessageId: "m1", tokensBefore: 1, at: "t" });
|
const oldMarker = newCompactionMarker("OLD", { firstKeptMessageId: "m1", tokensBefore: 1, at: "t" });
|
||||||
const m1 = newUserMessage("x");
|
const m1 = newUserMessage("x");
|
||||||
@@ -303,6 +335,11 @@ describe("serializeForSummary + buildCompactionPrompt", () => {
|
|||||||
assert.ok(!text.toLowerCase().includes("plan-block"), "UI-only markers never serialize");
|
assert.ok(!text.toLowerCase().includes("plan-block"), "UI-only markers never serialize");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves legacy summary text when the caller has no separately supplied prior summary", () => {
|
||||||
|
const marker = newCompactionMarker("LEGACY SUMMARY", { firstKeptMessageId: "missing", tokensBefore: 1, at: "fixture" });
|
||||||
|
assert.ok(serializeForSummary([marker]).includes("LEGACY SUMMARY"));
|
||||||
|
});
|
||||||
|
|
||||||
it("the prompt demands the four sections in order and carries the plan tree verbatim", () => {
|
it("the prompt demands the four sections in order and carries the plan tree verbatim", () => {
|
||||||
const prompt = buildCompactionPrompt({ spanText: "TRANSCRIPT", planTreeText: "- [done] A (a1)\n- A → B", kind: "teach" });
|
const prompt = buildCompactionPrompt({ spanText: "TRANSCRIPT", planTreeText: "- [done] A (a1)\n- A → B", kind: "teach" });
|
||||||
const order = ["The learner's understanding", "The teaching arc", "The plan dependency tree", "The current moment"].map(
|
const order = ["The learner's understanding", "The teaching arc", "The plan dependency tree", "The current moment"].map(
|
||||||
@@ -388,6 +425,11 @@ describe("toLlmMessages carries the leading system message (request chrome)", ()
|
|||||||
const record = (): LhMessage[] => [
|
const record = (): LhMessage[] => [
|
||||||
{ id: "old", role: "user", content: "dropped history", timestamp: 1 },
|
{ id: "old", role: "user", content: "dropped history", timestamp: 1 },
|
||||||
{ id: "kept", role: "user", content: "kept tail", timestamp: 2 },
|
{ id: "kept", role: "user", content: "kept tail", timestamp: 2 },
|
||||||
|
newCompactionMarker("SUPERSEDED SUMMARY", {
|
||||||
|
firstKeptMessageId: "kept",
|
||||||
|
tokensBefore: 98,
|
||||||
|
at: new Date(0).toISOString(),
|
||||||
|
}),
|
||||||
newCompactionMarker("SUMMARY", {
|
newCompactionMarker("SUMMARY", {
|
||||||
firstKeptMessageId: "kept",
|
firstKeptMessageId: "kept",
|
||||||
tokensBefore: 99,
|
tokensBefore: 99,
|
||||||
@@ -407,6 +449,8 @@ describe("toLlmMessages carries the leading system message (request chrome)", ()
|
|||||||
: ((m.content as { text?: string }[])[0]?.text ?? "");
|
: ((m.content as { text?: string }[])[0]?.text ?? "");
|
||||||
assert.ok(out.slice(2).some((m) => m.role === "user" && textOf(m) === "kept tail"));
|
assert.ok(out.slice(2).some((m) => m.role === "user" && textOf(m) === "kept tail"));
|
||||||
assert.ok(!out.some((m) => m.role === "user" && textOf(m) === "dropped history"));
|
assert.ok(!out.some((m) => m.role === "user" && textOf(m) === "dropped history"));
|
||||||
|
assert.ok(!JSON.stringify(out).includes("SUPERSEDED SUMMARY"));
|
||||||
|
assert.equal(JSON.stringify(out).split(COMPACTED_HISTORY_HEADER).length - 1, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("without a cut the chrome renders EXACTLY ONCE (no duplicate)", () => {
|
it("without a cut the chrome renders EXACTLY ONCE (no duplicate)", () => {
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { repairMathForDisplay, restoreMathEscapes, transformMathDelimiters } from "../src/core/math-delims.ts";
|
||||||
|
|
||||||
|
const math = String.raw;
|
||||||
|
test("document paragraphs and display framing recover without interpreting n-commands as newlines", () => {
|
||||||
|
const source = math`First.\n\nSecond: $\nabla f\neq0$.\n\n$$\n\frac{1}{2}\n$$`;
|
||||||
|
assert.equal(repairMathForDisplay(source), "First.\n\nSecond: " + math`$\nabla f\neq0$.` + "\n\n$$\n" + math`\frac{1}{2}` + "\n$$");
|
||||||
|
});
|
||||||
|
test("literal document escapes fused to unknown math commands use document context", () => {
|
||||||
|
const source = math`Jacobian:\n\n$$\nJ=\begin{pmatrix}1&2\\3&4\end{pmatrix}\n$$`;
|
||||||
|
assert.equal(repairMathForDisplay(source), "Jacobian:\n\n$$\n" + math`J=\begin{pmatrix}1&2\\3&4\end{pmatrix}` + "\n$$");
|
||||||
|
});
|
||||||
|
test("encoded environment profiles decode row separators by their factor, not to one slash", () => {
|
||||||
|
for (const [source, expected] of [
|
||||||
|
[math`$\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$`, math`$\begin{pmatrix}a&b\\c&d\end{pmatrix}$`],
|
||||||
|
[math`$\\begin{pmatrix}1&2\\\\3&4\\end{pmatrix}$`, math`$\begin{pmatrix}1&2\\3&4\end{pmatrix}$`],
|
||||||
|
[math`$\\begin{pmatrix}a&b\\\\\\nabla f&d\\end{pmatrix}$`, math`$\begin{pmatrix}a&b\\\nabla f&d\end{pmatrix}$`],
|
||||||
|
[math`$\\substack{a\\\\beta}$`, math`$\substack{a\\beta}$`],
|
||||||
|
] as const) {
|
||||||
|
assert.equal(repairMathForDisplay(source), expected);
|
||||||
|
assert.equal(repairMathForDisplay(expected), expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("native substack rows and literal document escapes in genuine comments remain literal", () => {
|
||||||
|
const source = math`$\substack{a\\beta}$`;
|
||||||
|
assert.equal(repairMathForDisplay(source), source);
|
||||||
|
const comment = "$x % " + math`literal \n\n` + "\ny$";
|
||||||
|
assert.equal(repairMathForDisplay(comment), comment);
|
||||||
|
});
|
||||||
|
test("inline lost prefixes recover only known full commands; display newlines remain whitespace", () => {
|
||||||
|
assert.equal(repairMathForDisplay("$\nabla f$"), math`$\nabla f$`);
|
||||||
|
assert.equal(repairMathForDisplay("$x+\ny$"), "$x+\ny$");
|
||||||
|
assert.equal(repairMathForDisplay("$x+\nu$"), math`$x+\nu$`); // approved typical-inline-authoring policy
|
||||||
|
assert.equal(repairMathForDisplay("$$\nabla f\n$$"), "$$\nabla f\n$$"); // display source whitespace
|
||||||
|
});
|
||||||
|
test("physical display newlines and genuine row breaks/comments survive", () => {
|
||||||
|
const source = "$$\n" + math`\begin{aligned}a&=b\\% row comment` + "\nc&=d\end{aligned}\n$$";
|
||||||
|
assert.equal(repairMathForDisplay(source), source);
|
||||||
|
});
|
||||||
|
test("proper nu survives every context, including fields with document escapes", () => {
|
||||||
|
for (const source of [math`$\nu$`, "$$\n" + math`\nu` + "\n$$", math`$\nabla f+\nu+\neq$`]) {
|
||||||
|
assert.equal(repairMathForDisplay(source), source);
|
||||||
|
}
|
||||||
|
assert.equal(repairMathForDisplay(math`First.\n\n$\nu$`), "First.\n\n" + math`$\nu$`);
|
||||||
|
assert.equal(repairMathForDisplay("$$\nu\n$$"), "$$\nu\n$$"); // LF + variable u in display
|
||||||
|
});
|
||||||
|
test("tabs recover known commands including to, not arbitrary whitespace", () => {
|
||||||
|
assert.equal(repairMathForDisplay("$$\nx\t+\ty\n$$"), "$$\nx\t+\ty\n$$");
|
||||||
|
assert.equal(repairMathForDisplay("$a\to b$"), math`$a\to b$`);
|
||||||
|
assert.equal(repairMathForDisplay("$$\na\to b\n$$"), "$$\n" + math`a\to b` + "\n$$");
|
||||||
|
assert.equal(repairMathForDisplay("$$\r\nx+1\r\n$$"), "$$\r\nx+1\r\n$$");
|
||||||
|
});
|
||||||
|
test("Markdown containers protect literal code and do not swallow following content", () => {
|
||||||
|
for (const source of [
|
||||||
|
"> ~~~text\n> " + math`$\\alpha$ \n\n` + "\n> ~~~",
|
||||||
|
"> > ~~~text\n> > " + math`$\\alpha$` + "\n> > ~~~",
|
||||||
|
"- ~~~text\n " + math`$\\alpha$` + "\n ~~~",
|
||||||
|
"> ~~~text\n> " + math`$\\alpha$` + "\n\n~~~text\n" + math`$\\beta$` + "\n~~~",
|
||||||
|
]) {
|
||||||
|
assert.equal(repairMathForDisplay(source), source);
|
||||||
|
assert.equal(transformMathDelimiters(source), source);
|
||||||
|
assert.equal(repairMathForDisplay(source + "\n\n" + math`$\\gamma$`), source + "\n\n" + math`$\gamma$`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("numeric percentages recover, including percentages inside text groups", () => {
|
||||||
|
for (const [source, expected] of [
|
||||||
|
[math`$60\\%$`, math`$60\%$`],
|
||||||
|
[math`$50\\%+10\\%=60\\%$`, math`$50\%+10\%=60\%$`],
|
||||||
|
[math`$\text{ (50\\%)}$`, math`$\text{ (50\%)}$`],
|
||||||
|
] as const) assert.equal(repairMathForDisplay(source), expected);
|
||||||
|
});
|
||||||
|
test("numeric row breaks followed by genuine comments are not percentage escapes", () => {
|
||||||
|
const source = "$60\\\\% explanation of this row\nx$";
|
||||||
|
assert.equal(repairMathForDisplay(source), source);
|
||||||
|
const ambiguous = math`$x\\%$`;
|
||||||
|
assert.equal(repairMathForDisplay(ambiguous), ambiguous);
|
||||||
|
});
|
||||||
|
test("inline and fenced code retain all bytes, including legacy delimiters and actual control characters", () => {
|
||||||
|
const code = "```text\n\\(x\nabla\\)\n\\n\\n\n```\n" + "`" + math`\n\n $60\\%$ \(x\)` + "`";
|
||||||
|
assert.equal(repairMathForDisplay(code), code);
|
||||||
|
assert.equal(restoreMathEscapes(code), code);
|
||||||
|
});
|
||||||
|
test("math matching cannot consume a blank paragraph or a code span", () => {
|
||||||
|
const source = "Price $5.\n\nThen $x$ and `code` remains.";
|
||||||
|
assert.equal(repairMathForDisplay(source), source);
|
||||||
|
assert.equal(repairMathForDisplay("$not `code` math$"), "$not `code` math$");
|
||||||
|
});
|
||||||
|
test("Windows paths and correctly written n-commands are not document escapes", () => {
|
||||||
|
const source = math`Path C:\new\notes; symbols $\nu+\ni+\neq+\nabla f$.\n\nNext paragraph.`;
|
||||||
|
assert.equal(repairMathForDisplay(source), math`Path C:\new\notes; symbols $\nu+\ni+\neq+\nabla f$.` + "\n\nNext paragraph.");
|
||||||
|
});
|
||||||
|
test("declared n-prefixed macros are commands, not document escapes", () => {
|
||||||
|
const source = math`$\newcommand{\nThing}{x}\nThing$.\n\nNext.`;
|
||||||
|
assert.equal(repairMathForDisplay(source), math`$\newcommand{\nThing}{x}\nThing$.` + "\n\nNext.");
|
||||||
|
});
|
||||||
|
test("partial recovery cannot turn otherwise parseable TeX into a missing-argument error", () => {
|
||||||
|
const source = math`$x\\frac{a}$`;
|
||||||
|
assert.equal(repairMathForDisplay(source), source);
|
||||||
|
});
|
||||||
|
test("legacy compatibility and recovery share code barriers, including tilde and indentation", () => {
|
||||||
|
for (const source of [" " + math`\(x\) \n\n`, "\\[\n~~~tex\n" + math`\n\n \(x\)` + "\n\\]\n~~~"]) {
|
||||||
|
assert.equal(repairMathForDisplay(source), source);
|
||||||
|
assert.equal(transformMathDelimiters(source), source);
|
||||||
|
}
|
||||||
|
const comment = "$60% " + math`\n\n` + "\nx$";
|
||||||
|
assert.equal(repairMathForDisplay(comment), comment);
|
||||||
|
});
|
||||||
|
test("recovery converges after one pass, including multiple debris characters", () => {
|
||||||
|
for (const source of ["$\forall x,\theta+\to$", math`First.\n\n$$\nJ=\\frac{1}{2}+60\\%\n$$`,
|
||||||
|
math`$\begin{pmatrix}a&b\\c&d\end{pmatrix}$`, "```text\n\\(\nabla\\)\n```", "$\nabla f$",
|
||||||
|
]) {
|
||||||
|
const once = repairMathForDisplay(source);
|
||||||
|
assert.equal(repairMathForDisplay(once), once);
|
||||||
|
}
|
||||||
|
});
|
||||||
+12
-21
@@ -6,7 +6,7 @@
|
|||||||
* 1. bridge-script injection (buildDemoSrcDoc) + the pure bridge-message
|
* 1. bridge-script injection (buildDemoSrcDoc) + the pure bridge-message
|
||||||
* reducer (reduceDemoBridgeState)
|
* reducer (reduceDemoBridgeState)
|
||||||
* 2. card rendering (static markup): sandboxed iframe attrs, error chip,
|
* 2. card rendering (static markup): sandboxed iframe attrs, error chip,
|
||||||
* failure notes, inline sanitized svg
|
* failure notes, SVG image documents
|
||||||
* 3. the demo-builder loop with a FAKE model + FAKE headless (success with
|
* 3. the demo-builder loop with a FAKE model + FAKE headless (success with
|
||||||
* a fix iteration, untested-final verification, never-clean failure,
|
* a fix iteration, untested-final verification, never-clean failure,
|
||||||
* no-Chrome failure)
|
* no-Chrome failure)
|
||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
INITIAL_DEMO_BRIDGE_STATE,
|
INITIAL_DEMO_BRIDGE_STATE,
|
||||||
reduceDemoBridgeState,
|
reduceDemoBridgeState,
|
||||||
} from "../components/artifacts/demo-card.tsx";
|
} from "../components/artifacts/demo-card.tsx";
|
||||||
import { sanitizeSvg, svgNaturalWidth, VisualCard, VisualOutputCard } from "../components/artifacts/visual-card.tsx";
|
import { svgNaturalWidth, VisualCard, VisualOutputCard } from "../components/artifacts/visual-card.tsx";
|
||||||
import { slugFilename } from "../components/artifacts/download.ts";
|
import { slugFilename } from "../components/artifacts/download.ts";
|
||||||
import { chartSize } from "../components/session-overlay.tsx";
|
import { chartSize } from "../components/session-overlay.tsx";
|
||||||
import { SubagentSteerCard } from "../components/artifacts/steer-card.tsx";
|
import { SubagentSteerCard } from "../components/artifacts/steer-card.tsx";
|
||||||
@@ -274,14 +274,14 @@ describe("dispatch card rendering (the tool-call ack box)", () => {
|
|||||||
assert.match(html, /Dispatched…/);
|
assert.match(html, /Dispatched…/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("settles to a quiet delivered one-liner when done (artifact lives in the steer)", () => {
|
it("settles to a quiet finished one-liner when the worker is done", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<JobsContext.Provider value={[mkJob("done", "delivered")]}>
|
<JobsContext.Provider value={[mkJob("done", "delivered")]}>
|
||||||
<JobCard part={ackPart as never} />
|
<JobCard part={ackPart as never} />
|
||||||
</JobsContext.Provider>,
|
</JobsContext.Provider>,
|
||||||
);
|
);
|
||||||
assert.match(html, /Research agent/);
|
assert.match(html, /Research agent/);
|
||||||
assert.match(html, /delivered/);
|
assert.match(html, /finished/);
|
||||||
assert.ok(!html.includes("web_search"), "no stale activity line once settled");
|
assert.ok(!html.includes("web_search"), "no stale activity line once settled");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -325,7 +325,7 @@ describe("dispatch card rendering (the tool-call ack box)", () => {
|
|||||||
assert.match(html, /pdf_render/);
|
assert.match(html, /pdf_render/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pdf dispatch settles to the delivered one-liner", () => {
|
it("pdf dispatch settles to the finished one-liner", () => {
|
||||||
const pdfJob: JobRecord = ({
|
const pdfJob: JobRecord = ({
|
||||||
id: "jp1",
|
id: "jp1",
|
||||||
kind: "pdf",
|
kind: "pdf",
|
||||||
@@ -342,7 +342,7 @@ describe("dispatch card rendering (the tool-call ack box)", () => {
|
|||||||
</JobsContext.Provider>,
|
</JobsContext.Provider>,
|
||||||
);
|
);
|
||||||
assert.match(html, /PDF reader/);
|
assert.match(html, /PDF reader/);
|
||||||
assert.match(html, /delivered/);
|
assert.match(html, /finished/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -373,19 +373,19 @@ describe("steer card rendering (the delivered artifact)", () => {
|
|||||||
assert.ok(!html.includes("<iframe"), "no iframe for a failed build");
|
assert.ok(!html.includes("<iframe"), "no iframe for a failed build");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the verified svg inline (sanitized); the maker's notes are glue and stay hidden", () => {
|
it("renders the verified svg as an image; the maker's notes are glue and stay hidden", () => {
|
||||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><circle cx="50" cy="50" r="20"/></svg>';
|
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><circle cx="50" cy="50" r="20"/></svg>';
|
||||||
const steer = buildSubagentSteer(
|
const steer = buildSubagentSteer(
|
||||||
{ id: "jv", kind: "visual", toolCallId: "cv" },
|
{ id: "jv", kind: "visual", toolCallId: "cv" },
|
||||||
{ svg, notes: "Looking at the render, the diagram is clean and accurate." },
|
{ svg, notes: "Looking at the render, the diagram is clean and accurate." },
|
||||||
);
|
);
|
||||||
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
|
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
|
||||||
assert.ok(html.includes("circle"), "svg content rendered inline");
|
assert.ok(html.includes(encodeURIComponent(svg)), "the image URL contains the original SVG");
|
||||||
assert.match(html, /Figure/);
|
assert.match(html, /Figure/);
|
||||||
// Natural sizing (owner, 2026-10-07): a DECLARED width (200) means the
|
// Natural sizing (owner, 2026-10-07): a DECLARED width (200) means the
|
||||||
// holder hugs + centers rather than stretching full-width.
|
// holder hugs + centers rather than stretching full-width.
|
||||||
assert.match(html, /w-fit/, "holder hugs the declared size");
|
assert.match(html, /w-fit/, "holder hugs the declared size");
|
||||||
assert.match(html, /svg\]:max-w-full/, "svg shrinks to fit only when oversized");
|
assert.match(html, /class="block h-auto max-w-full"/, "image shrinks to fit only when oversized");
|
||||||
assert.ok(!html.includes("Looking at the render"), "model-facing notes never render");
|
assert.ok(!html.includes("Looking at the render"), "model-facing notes never render");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -590,17 +590,8 @@ describe("setSteerUiInverted (the persisted invert switch, 2026-10-07)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sanitizeSvg", () => {
|
// Generated-figure image boundaries are covered in svg-image.test.tsx.
|
||||||
it("strips script blocks, event handlers, and javascript: urls, keeps geometry", () => {
|
// There is no longer a partial regex sanitizer in the display path.
|
||||||
const dirty = '<svg width="10" height="10" onload="alert(1)"><script>alert(2)</script><circle r="1" onclick="pwn()"/><a href="javascript:alert(3)"><rect width="2" height="2"/></a></svg>';
|
|
||||||
const clean = sanitizeSvg(dirty);
|
|
||||||
assert.ok(!/<script/i.test(clean), "script tags removed");
|
|
||||||
assert.ok(!/\sonload/i.test(clean), "onload removed");
|
|
||||||
assert.ok(!/\sonclick/i.test(clean), "onclick removed");
|
|
||||||
assert.ok(!/javascript:/i.test(clean), "javascript: urls removed");
|
|
||||||
assert.ok(clean.includes("circle") && clean.includes("rect"), "geometry preserved");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── 3. the demo-builder loop ────────────────────────────────────────────────
|
// ─── 3. the demo-builder loop ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -848,7 +839,7 @@ describe("demo/visual job runners", () => {
|
|||||||
|
|
||||||
const done = jobStore.getJob(job.id)!;
|
const done = jobStore.getJob(job.id)!;
|
||||||
assert.equal(done.status, "done");
|
assert.equal(done.status, "done");
|
||||||
assert.equal(done.activity, "delivered", "the fake deliver flips status like the wake pipeline");
|
assert.equal(done.activity, "finished", "the worker finishes independently of delivery");
|
||||||
const result = done.result as { title: string; html: string; tested: boolean; notes?: string };
|
const result = done.result as { title: string; html: string; tested: boolean; notes?: string };
|
||||||
assert.equal(result.title, "Compound interest");
|
assert.equal(result.title, "Compound interest");
|
||||||
assert.equal(result.html, GOOD_HTML);
|
assert.equal(result.html, GOOD_HTML);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const mainRequire = createRequire(mainFile);
|
|||||||
/** Decline the single-instance lock so normal boot never spawns a server.
|
/** Decline the single-instance lock so normal boot never spawns a server.
|
||||||
* Then call the REAL createWindow with a stub Electron API and inspect its
|
* Then call the REAL createWindow with a stub Electron API and inspect its
|
||||||
* constructor options. No rewritten copy of the production logic. */
|
* constructor options. No rewritten copy of the production logic. */
|
||||||
function windowOptions(platform: string) {
|
function windowOptions(platform: string, env: Record<string, string> = {}) {
|
||||||
class FakeWindow {
|
class FakeWindow {
|
||||||
constructor(readonly options: unknown) {}
|
constructor(readonly options: unknown) {}
|
||||||
webContents = { on() {}, setWindowOpenHandler() {} };
|
webContents = { on() {}, setWindowOpenHandler() {} };
|
||||||
@@ -23,13 +23,13 @@ function windowOptions(platform: string) {
|
|||||||
once() {}
|
once() {}
|
||||||
}
|
}
|
||||||
const electron = {
|
const electron = {
|
||||||
app: { setName() {}, requestSingleInstanceLock: () => false, quit() {} },
|
app: { setName() {}, getPath: () => "/fixture/LearnHarness", setPath() {}, requestSingleInstanceLock: () => false, quit() {} },
|
||||||
BrowserWindow: FakeWindow,
|
BrowserWindow: FakeWindow,
|
||||||
session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler() {} } },
|
session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler() {} } },
|
||||||
};
|
};
|
||||||
const context = {
|
const context = {
|
||||||
require: (id: string) => id === "electron" ? electron : mainRequire(id),
|
require: (id: string) => id === "electron" ? electron : mainRequire(id),
|
||||||
process: { platform, argv: [], env: {}, on() {} },
|
process: { platform, argv: [], env, on() {} },
|
||||||
__dirname: dirname(mainFile),
|
__dirname: dirname(mainFile),
|
||||||
};
|
};
|
||||||
runInNewContext(mainSource, context, { filename: mainFile });
|
runInNewContext(mainSource, context, { filename: mainFile });
|
||||||
@@ -61,6 +61,24 @@ describe("desktop window chrome", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it("only the external dev shell receives the web subscription transport marker", () => {
|
||||||
|
assert.equal(windowOptions("darwin").webPreferences.additionalArguments, undefined);
|
||||||
|
assert.deepEqual(windowOptions("darwin", { LH_DEV_URL: "http://localhost:3000" }).webPreferences.additionalArguments, ["--lh-web-subscriptions"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dev preload routes opaque sign-in actions through its narrow external-browser channel", async () => {
|
||||||
|
let bridge!: { subscriptionTransport: string; openSubscriptionBrowser(action: string): Promise<void> };
|
||||||
|
const invoked: unknown[][] = [];
|
||||||
|
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), {
|
||||||
|
process: { platform: "darwin", argv: ["--lh-web-subscriptions"] },
|
||||||
|
require: () => ({ contextBridge: { exposeInMainWorld: (_: string, value: typeof bridge) => { bridge = value; } }, ipcRenderer: { on() {}, invoke: async (...args: unknown[]) => { invoked.push(args); } } }),
|
||||||
|
});
|
||||||
|
assert.equal(bridge.subscriptionTransport, "web");
|
||||||
|
const action = "/api/subscriptions/browser?id=00000000-0000-4000-8000-000000000001";
|
||||||
|
await bridge.openSubscriptionBrowser(action);
|
||||||
|
assert.deepEqual(invoked, [["lh:subscriptions:web-browser", action]]);
|
||||||
|
});
|
||||||
|
|
||||||
it("preload exposes OS identity without exposing Node/Electron to the renderer", () => {
|
it("preload exposes OS identity without exposing Node/Electron to the renderer", () => {
|
||||||
let bridge: Record<string, unknown> = {};
|
let bridge: Record<string, unknown> = {};
|
||||||
const context = {
|
const context = {
|
||||||
@@ -72,18 +90,52 @@ describe("desktop window chrome", () => {
|
|||||||
bridge = api;
|
bridge = api;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ipcRenderer: {},
|
ipcRenderer: { on() {} },
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
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", "expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform", "subscriptions",
|
"chatgpt", "checkForUpdates", "expandWindow", "getUpdateState", "getZoom", "installUpdate", "onOpenSettings", "onUpdateStatus", "onZoom", "platform", "subscriptions",
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(Object.keys(bridge.subscriptions as object).sort(), ["cancel", "consent", "disconnect", "login", "onChange", "open", "respond", "retry", "status"]);
|
assert.deepEqual(Object.keys(bridge.subscriptions as object).sort(), ["cancel", "consent", "disconnect", "login", "onChange", "open", "respond", "retry", "status"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("update preload bridge", () => {
|
||||||
|
it("buffers the menu request, unsubscribes safely, and uses the matching snapshot/check channels", async () => {
|
||||||
|
let bridge!: {
|
||||||
|
onOpenSettings: (cb: (section: string) => void) => () => void;
|
||||||
|
getUpdateState: () => Promise<unknown>;
|
||||||
|
checkForUpdates: () => Promise<void>;
|
||||||
|
};
|
||||||
|
const handlers = new Map<string, (event: unknown, value: string) => void>();
|
||||||
|
const invoked: string[] = [];
|
||||||
|
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), {
|
||||||
|
process: { platform: "darwin" },
|
||||||
|
require: () => ({
|
||||||
|
contextBridge: { exposeInMainWorld: (_: string, value: typeof bridge) => { bridge = value; } },
|
||||||
|
ipcRenderer: {
|
||||||
|
on: (channel: string, listener: (event: unknown, value: string) => void) => handlers.set(channel, listener),
|
||||||
|
invoke: async (channel: string) => { invoked.push(channel); },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const send = () => handlers.get("lh:open-settings")!({}, "general");
|
||||||
|
const seen: string[] = [];
|
||||||
|
send(); const off = bridge.onOpenSettings(section => seen.push(section));
|
||||||
|
assert.deepEqual(seen, ["general"], "pre-subscription request is delivered once");
|
||||||
|
send(); assert.deepEqual(seen, ["general", "general"]);
|
||||||
|
off(); send(); assert.equal(seen.length, 2);
|
||||||
|
const next: string[] = []; const offNext = bridge.onOpenSettings(section => next.push(section));
|
||||||
|
assert.deepEqual(next, ["general"]);
|
||||||
|
off(); send(); assert.equal(next.length, 2, "old cleanup cannot remove a newer subscriber");
|
||||||
|
offNext();
|
||||||
|
await bridge.getUpdateState(); await bridge.checkForUpdates();
|
||||||
|
assert.deepEqual(invoked, ["lh:update:get", "lh:update:check"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("desktop packaging policy", () => {
|
describe("desktop packaging policy", () => {
|
||||||
const yaml = require("js-yaml") as { load(source: string): Record<string, any> };
|
const yaml = require("js-yaml") as { load(source: string): Record<string, any> };
|
||||||
const config = yaml.load(readFileSync(new URL("../electron-builder.yml", import.meta.url), "utf8"));
|
const config = yaml.load(readFileSync(new URL("../electron-builder.yml", import.meta.url), "utf8"));
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import React, { act, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
(globalThis as Record<string, unknown>).React = React;
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { parseHTML } from "linkedom";
|
||||||
|
import { QuestionDock } from "../components/question-dock.tsx";
|
||||||
|
import { PracticeDock } from "../components/practice-dock.tsx";
|
||||||
|
|
||||||
|
const dom = parseHTML("<html><body></body></html>");
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
window: dom.window, document: dom.document, HTMLElement: dom.window.HTMLElement,
|
||||||
|
IS_REACT_ACT_ENVIRONMENT: true,
|
||||||
|
});
|
||||||
|
const observers: TestObserver[] = [];
|
||||||
|
class TestObserver {
|
||||||
|
elements = new Set<Element>();
|
||||||
|
disconnected = false;
|
||||||
|
constructor(readonly callback: () => void) { observers.push(this); }
|
||||||
|
observe(el: Element) { this.elements.add(el); }
|
||||||
|
disconnect() { this.disconnected = true; this.elements.clear(); }
|
||||||
|
}
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
ResizeObserver: TestObserver,
|
||||||
|
getComputedStyle: (el: HTMLElement) => ({
|
||||||
|
borderTopWidth: el.dataset.part === "shell" ? "1px" : "0px",
|
||||||
|
borderBottomWidth: el.dataset.part === "shell" ? "1px" : "0px",
|
||||||
|
paddingTop: el.hasAttribute("data-dock-scroll-area") ? "12px" : "0px",
|
||||||
|
paddingBottom: "0px",
|
||||||
|
overflowY: el.style.overflowY || (el.classList.contains("overflow-y-auto") ? "auto" : "visible"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
// Import after installing window so the hook uses its browser layout effect.
|
||||||
|
const { dockHeightBudget, syncDockHeight, useDockHeight, forwardDockWheel } = await import("../components/dock-height.ts");
|
||||||
|
|
||||||
|
const normal = {
|
||||||
|
columnHeight: 700, outsideHeight: 60, beforeShellHeight: 0,
|
||||||
|
shellBorderHeight: 2, contentHeight: 800, questionHeight: 600,
|
||||||
|
questionPaddingHeight: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("dockHeightBudget — whole dock, not just its question", () => {
|
||||||
|
it("subtracts answer controls, toolbar, borders and breathing gap", () => {
|
||||||
|
assert.deepEqual(dockHeightBudget(normal), {
|
||||||
|
viewportMaxHeight: 608, shellMaxHeight: 608, questionMaxHeight: 406, scrollWholeContent: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("accounts for growing answer fields and the separate reading draft", () => {
|
||||||
|
const result = dockHeightBudget({ ...normal, beforeShellHeight: 170, contentHeight: 880 });
|
||||||
|
assert.equal(result.questionMaxHeight, 156);
|
||||||
|
assert.equal(result.shellMaxHeight, 438);
|
||||||
|
});
|
||||||
|
it("preserves a computed zero and deliberately selects whole-content scrolling", () => {
|
||||||
|
const result = dockHeightBudget({ ...normal, columnHeight: 292 });
|
||||||
|
assert.equal(result.questionMaxHeight, 0);
|
||||||
|
assert.equal(result.scrollWholeContent, true);
|
||||||
|
});
|
||||||
|
it("switches before padding would leave a zero-sized content viewport, without an invented minimum", () => {
|
||||||
|
assert.equal(dockHeightBudget({ ...normal, columnHeight: 306 }).scrollWholeContent, true);
|
||||||
|
assert.equal(dockHeightBudget({ ...normal, columnHeight: 307 }).scrollWholeContent, false);
|
||||||
|
});
|
||||||
|
it("never creates negative caps, even if the toolbar exceeds the column", () => {
|
||||||
|
const result = dockHeightBudget({ ...normal, columnHeight: 20 });
|
||||||
|
assert.equal(result.viewportMaxHeight, 0);
|
||||||
|
assert.equal(result.shellMaxHeight, 0);
|
||||||
|
assert.equal(result.questionMaxHeight, 0);
|
||||||
|
assert.equal(result.scrollWholeContent, true);
|
||||||
|
});
|
||||||
|
it("collapsed bars use their visible height, and a tall reading draft can still scroll", () => {
|
||||||
|
assert.equal(dockHeightBudget({ ...normal, questionHeight: null, contentHeight: 42 }).scrollWholeContent, false);
|
||||||
|
assert.equal(dockHeightBudget({ ...normal, questionHeight: null, contentHeight: 42, beforeShellHeight: 650 }).scrollWholeContent, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// These mocked dimensions test the DOM/lifecycle seam, not real CSS layout.
|
||||||
|
// Live-browser checks separately exercise actual field sizing and WAAPI.
|
||||||
|
const sizes: Record<string, number> = {};
|
||||||
|
let animating = false;
|
||||||
|
dom.window.HTMLElement.prototype.getAnimations = () => animating ? [{} as Animation] : [];
|
||||||
|
const resetSizes = () => Object.assign(sizes, { column: 700, block: 862, viewport: 802, body: 802, shell: 802, content: 800, question: 600 });
|
||||||
|
const originalRect = dom.window.HTMLElement.prototype.getBoundingClientRect;
|
||||||
|
dom.window.HTMLElement.prototype.getBoundingClientRect = function () {
|
||||||
|
const part = this.hasAttribute("data-dock-scroll-area") ? "question" : this.dataset.part;
|
||||||
|
if (part && part in sizes) return { height: sizes[part]!, width: 700, x: 0, y: 0, top: 0, left: 0, bottom: sizes[part]!, right: 700, toJSON() {} };
|
||||||
|
return originalRect.call(this);
|
||||||
|
};
|
||||||
|
Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", { configurable: true, get() { return this.dataset.part === "column" ? sizes.column : Number(this.dataset.clientHeight ?? 0); } });
|
||||||
|
Object.defineProperty(dom.window.HTMLElement.prototype, "clientWidth", { configurable: true, get() { return this.dataset.part === "column" ? 680 : 700; } });
|
||||||
|
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get() { return 700; } });
|
||||||
|
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", { configurable: true, get() { return Number(this.dataset.scrollHeight ?? 0); } });
|
||||||
|
|
||||||
|
const ignoreHeight = () => {};
|
||||||
|
function Fixture({ expanded = true, practice = false, enabled = true, inspected, onHeight = ignoreHeight }: {
|
||||||
|
expanded?: boolean; practice?: boolean; enabled?: boolean; inspected?: (block: HTMLDivElement) => void;
|
||||||
|
onHeight?: (height: number) => void;
|
||||||
|
}) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const blockRef = useRef<HTMLDivElement>(null);
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const bodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
const shellRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [inset, setInset] = useState(0);
|
||||||
|
useDockHeight({ scrollRef, blockRef, viewportRef, bodyRef, shellRef, contentRef,
|
||||||
|
expanded, enabled, mode: practice ? "practice:p1" : "question:q1", onScrollbarInset: setInset, onHeight });
|
||||||
|
// Like DockOverlay's later morph/reserve layout effect: constraints must
|
||||||
|
// already be applied in this same commit, not an after-paint correction.
|
||||||
|
useLayoutEffect(() => { if (blockRef.current) inspected?.(blockRef.current); });
|
||||||
|
return <><div ref={scrollRef} data-part="column" /><div ref={blockRef} data-part="block" data-inset={inset}>
|
||||||
|
<div ref={viewportRef} data-part="viewport"><div ref={bodyRef} data-part="body">
|
||||||
|
<div ref={shellRef} data-part="shell"><div ref={contentRef} data-part="content" key={practice ? "practice" : "question"}>
|
||||||
|
{practice ? <PracticeDock problemCallId="p1" input={{ statement: "Long practice problem" }} expanded={expanded} onExpand={() => {}} onSubmit={() => {}} />
|
||||||
|
: <QuestionDock toolName="probe" toolCallId="q1" input={{ question: "Long teaching question", options: [{ label: "A" }, { label: "B" }], correctAnswer: "A", explanation: "A is correct." }} expanded={expanded} dictationClaim={false} onExpand={() => {}} onSubmit={() => {}} />}
|
||||||
|
</div></div>
|
||||||
|
</div></div><div data-testid="toolbar" />
|
||||||
|
</div></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withFixture(run: (host: HTMLElement, render: (props?: React.ComponentProps<typeof Fixture>) => Promise<void>) => Promise<void>) {
|
||||||
|
resetSizes();
|
||||||
|
const host = document.createElement("div"); document.body.append(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
const render = async (props = {}) => { await act(async () => root.render(<Fixture {...props} />)); };
|
||||||
|
try { await run(host, render); }
|
||||||
|
finally { await act(async () => root.unmount()); host.remove(); }
|
||||||
|
}
|
||||||
|
const part = (host: HTMLElement, name: string) => host.querySelector<HTMLElement>(`[data-part="${name}"]`)!;
|
||||||
|
|
||||||
|
describe("measured question/practice DOM and observer lifecycle", () => {
|
||||||
|
it("applies constraints before the morph layout effect and handles classic scrollbar insets", async () => {
|
||||||
|
await withFixture(async (host, render) => {
|
||||||
|
await render({ inspected: block => assert.equal(block.style.getPropertyValue("--dock-max-h"), "406px") });
|
||||||
|
assert.equal(part(host, "block").dataset.inset, "10");
|
||||||
|
assert.equal(part(host, "viewport").style.overflowY, "visible");
|
||||||
|
assert.ok(!part(host, "viewport").contains(host.querySelector('[data-testid="toolbar"]')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("excludes the animated/clamped shell height rather than treating it as answer chrome", async () => {
|
||||||
|
await withFixture(async (host, render) => {
|
||||||
|
await render();
|
||||||
|
Object.assign(sizes, { block: 160, viewport: 100, body: 100, shell: 100 });
|
||||||
|
await act(async () => observers.at(-1)!.callback());
|
||||||
|
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("ignores the still-mounted hidden teaching form and sizes it synchronously on reveal", async () => {
|
||||||
|
await withFixture(async (host, render) => {
|
||||||
|
Object.assign(sizes, { block: 104, viewport: 44, body: 44, shell: 44, content: 42, question: 2000 });
|
||||||
|
await render({ expanded: false });
|
||||||
|
assert.ok(host.querySelector('[data-dock-scroll-area]'), "teaching form remains mounted");
|
||||||
|
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h") || "", "");
|
||||||
|
assert.equal(part(host, "viewport").style.overflowY, "visible");
|
||||||
|
resetSizes();
|
||||||
|
await render({ expanded: true, inspected: block => assert.equal(block.style.getPropertyValue("--dock-max-h"), "406px") });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("reconnects to practice's newly mounted question after collapsing and revealing", async () => {
|
||||||
|
await withFixture(async (host, render) => {
|
||||||
|
Object.assign(sizes, { block: 104, viewport: 44, body: 44, shell: 44, content: 42 });
|
||||||
|
await render({ practice: true, expanded: false });
|
||||||
|
const collapsedObserver = observers.at(-1)!;
|
||||||
|
assert.equal(host.querySelector('[data-dock-scroll-area]'), null);
|
||||||
|
resetSizes();
|
||||||
|
await render({ practice: true, expanded: true });
|
||||||
|
assert.equal(collapsedObserver.disconnected, true);
|
||||||
|
assert.ok(observers.at(-1)!.elements.has(host.querySelector('[data-dock-scroll-area]')!));
|
||||||
|
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("intentionally uncaps the question for a short window, then restores the measured cap on growth", async () => {
|
||||||
|
await withFixture(async (host, render) => {
|
||||||
|
sizes.column = 292;
|
||||||
|
await render();
|
||||||
|
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "none");
|
||||||
|
assert.equal(part(host, "viewport").style.overflowY, "auto");
|
||||||
|
assert.equal(part(host, "viewport").style.maxHeight, "200px");
|
||||||
|
assert.equal(part(host, "shell").style.maxHeight, "none");
|
||||||
|
sizes.column = 700;
|
||||||
|
await act(async () => observers.at(-1)!.callback());
|
||||||
|
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h"), "406px");
|
||||||
|
assert.equal(part(host, "viewport").style.overflowY, "visible");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("removes constraints in composer mode and disconnects observers on unmount", async () => {
|
||||||
|
let last: TestObserver;
|
||||||
|
await withFixture(async (host, render) => {
|
||||||
|
await render();
|
||||||
|
await render({ enabled: false });
|
||||||
|
assert.equal(part(host, "block").style.getPropertyValue("--dock-max-h") || "", "");
|
||||||
|
assert.equal(part(host, "shell").style.maxHeight || "", "");
|
||||||
|
last = observers.at(-1)!;
|
||||||
|
});
|
||||||
|
assert.equal(last!.disconnected, true);
|
||||||
|
});
|
||||||
|
it("reports child-only resize heights to the reserve, but never mid-morph values", async () => {
|
||||||
|
await withFixture(async (_host, render) => {
|
||||||
|
const heights: number[] = [];
|
||||||
|
await render({ onHeight: h => heights.push(h) });
|
||||||
|
assert.equal(heights.at(-1), 862);
|
||||||
|
animating = true;
|
||||||
|
try {
|
||||||
|
sizes.block = 900;
|
||||||
|
await act(async () => observers.at(-1)!.callback());
|
||||||
|
assert.equal(heights.at(-1), 862, "animated block height was not reserved");
|
||||||
|
} finally { animating = false; }
|
||||||
|
await act(async () => observers.at(-1)!.callback());
|
||||||
|
assert.equal(heights.at(-1), 900, "settled child resize reaches the reserve without a parent render");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("reconnects to a new keyed mode even when expansion is unchanged", async () => {
|
||||||
|
await withFixture(async (host, render) => {
|
||||||
|
await render();
|
||||||
|
const previous = observers.at(-1)!;
|
||||||
|
const oldContent = part(host, "content");
|
||||||
|
await render({ practice: true });
|
||||||
|
assert.equal(previous.disconnected, true);
|
||||||
|
assert.notEqual(part(host, "content"), oldContent);
|
||||||
|
assert.ok(observers.at(-1)!.elements.has(part(host, "content")));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("keeps the DOM synchronizer deterministic when observed again with the same dimensions", async () => {
|
||||||
|
await withFixture(async (host, render) => {
|
||||||
|
await render();
|
||||||
|
const elements = { column: part(host, "column"), block: part(host, "block"), viewport: part(host, "viewport"),
|
||||||
|
body: part(host, "body"), shell: part(host, "shell"), content: part(host, "content") };
|
||||||
|
assert.deepEqual(syncDockHeight(elements, true), syncDockHeight(elements, true));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("wheel routing — question, short-window content, then transcript", () => {
|
||||||
|
it("keeps native wheel input in the question and then the whole-content viewport", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = '<div data-viewport style="overflow-y:auto"><div data-question style="overflow-y:auto"><span>text</span></div></div>';
|
||||||
|
const viewport = root.firstElementChild as HTMLElement;
|
||||||
|
const question = viewport.firstElementChild as HTMLElement;
|
||||||
|
const transcript = document.createElement("div"); transcript.scrollTop = 100;
|
||||||
|
for (const el of [viewport, question]) { el.dataset.scrollHeight = "400"; el.dataset.clientHeight = "100"; el.scrollTop = 50; }
|
||||||
|
const wheel = (deltaY: number, deltaMode = 0) => {
|
||||||
|
let prevented = false;
|
||||||
|
forwardDockWheel({ target: question.firstElementChild, deltaY, deltaMode, preventDefault() { prevented = true; } } as unknown as WheelEvent, root, transcript);
|
||||||
|
return prevented;
|
||||||
|
};
|
||||||
|
assert.equal(wheel(-20), false, "question can scroll up");
|
||||||
|
question.scrollTop = 0;
|
||||||
|
assert.equal(wheel(-20), false, "whole content can scroll up when question is at its edge");
|
||||||
|
viewport.scrollTop = 0;
|
||||||
|
assert.equal(wheel(-20), true);
|
||||||
|
assert.equal(transcript.scrollTop, 80);
|
||||||
|
question.scrollTop = viewport.scrollTop = 300;
|
||||||
|
assert.equal(wheel(1, 1), true);
|
||||||
|
assert.equal(transcript.scrollTop, 96, "line-mode wheel is normalized");
|
||||||
|
viewport.style.overflowY = "visible"; viewport.scrollTop = 50;
|
||||||
|
assert.equal(wheel(-20), false, "question still consumes upward movement");
|
||||||
|
question.style.overflowY = "hidden";
|
||||||
|
assert.equal(wheel(-20), true, "hidden and visible boxes are not scroll containers");
|
||||||
|
assert.equal(wheel(0), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user