diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..ae5a75d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,67 @@ +name: Bug report +description: Report something that is not working as expected. +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for helping improve LearnHarness. Describe what you observed; you do not need to know the cause. + + **This report is public.** Remove API keys, tokens, passwords and private information before posting. Never attach credential files or your whole data folder. Share only the learning content you are comfortable making public. + - type: textarea + id: description + attributes: + label: What happened? + description: Describe the problem and what you expected to happen instead. + validations: + required: true + - type: input + id: version + attributes: + label: LearnHarness version or build + description: Include the app version, installer filename or source commit. If you cannot find it, write "unknown". + validations: + required: true + - type: input + id: operating_system + attributes: + label: Operating system + description: For example, Windows, macOS or your Linux distribution. Include the OS version if known. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Steps or context (optional) + description: How can we reproduce it? If it is intermittent or you are unsure, describe what you were doing when it happened. + validations: + required: false + - type: input + id: installation + attributes: + label: Installation method (optional) + description: For example, desktop installer, AppImage, system package, self-hosted web or source build. + validations: + required: false + - type: input + id: provider_model + attributes: + label: Provider and model (if relevant) + description: Include the provider, model and API-key or subscription connection type if relevant. Do not include any keys or tokens. + validations: + required: false + - type: textarea + id: transcript_excerpt + attributes: + label: Sanitized raw JSON or chat-log excerpt (optional) + description: A few relevant entries around the affected turn or tool call are especially helpful. Paste the excerpt directly, preserving JSON escaping where possible. Remove credentials, personal information and private learning content first. Leave this blank if the records are difficult to find. + render: json + validations: + required: false + - type: textarea + id: screenshots + attributes: + label: Screenshots or other context (optional) + description: Attach screenshots or add other relevant details. Check for private information before posting. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0086358 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..456ac46 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,17 @@ +name: Feature request +description: Suggest a new feature or an improvement. +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + Ideas are welcome; you do not need a finished design or implementation plan. A request opens a discussion, not a commitment to build it. + + **This report is public.** Do not include credentials or private information. + - type: textarea + id: idea + attributes: + label: What would you like to see? + description: Describe your idea and the problem it would help you solve. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..29d8d91 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,15 @@ +name: Question +description: Ask about using LearnHarness. +labels: [question] +body: + - type: markdown + attributes: + value: | + **This report is public.** Do not include credentials or private information. + - type: textarea + id: question + attributes: + label: What is your question? + description: Include any relevant context. Version, OS, provider or model details can help when they relate to your question. + validations: + required: true diff --git a/.gitignore b/.gitignore index 320a543..5f72ce7 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ dist/ # dev auth/keys store (auth-keys wave): repo-root auth.json — secrets never committed auth.json +chatgpt-credentials.json* +github-copilot-credentials.json* +xai-credentials.json* # dev-fresh launcher scratch (fresh-user sim): disposable per launch .fresh-data/ diff --git a/README.md b/README.md index a9ff694..11bbbd1 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ Download · How it works · Documentation · - Issues + Issues · + Discord

LearnHarness uses AI agents to plan and teach lessons from your current understanding to your goal. It builds a detailed picture of what you understand and how you reason. That picture is updated as you learn. @@ -52,6 +53,8 @@ Add your provider API key and choose your models, then start a session with what All three platforms support in-app updates. +**Dictation:** Create a [Deepgram API key](https://console.deepgram.com/) with the **Member role or higher** ([required for token minting](https://developers.deepgram.com/reference/auth/tokens/grant)) and add it under **Settings → Providers → Dictation**. Choose the language below the key; **Multilingual** supports automatic recognition and mixed-language speech. A `403 Insufficient permissions` error during token minting can indicate an insufficient key role. + ## Your keys, your data - **Your keys and models.** No LearnHarness subscription or markup. You pay your providers for usage; a live cost meter shows model costs. diff --git a/app/api/chats/[id]/route.ts b/app/api/chats/[id]/route.ts index 1b19ce5..33f7e04 100644 --- a/app/api/chats/[id]/route.ts +++ b/app/api/chats/[id]/route.ts @@ -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 }> }) { const { id } = await params; @@ -19,6 +19,8 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str messages: chat.messages, pricing: sessionCost(chat), turnInFlight: isTurnRunning(id), + // Also reaches idle views that missed the failing turn's stream. + turnError: turnFailure(id), compacting: compactionInFlight(id), coverage: sessionCoverage(id), }); diff --git a/app/api/chats/[id]/stream/route.ts b/app/api/chats/[id]/stream/route.ts index 6dcc08f..7c56152 100644 --- a/app/api/chats/[id]/stream/route.ts +++ b/app/api/chats/[id]/stream/route.ts @@ -5,8 +5,9 @@ * 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 * live chunks until settle ([DONE]). No running turn → the cheap terminal - * ({"type":"turn-settled"} + [DONE]): the record is current, the client - * refetches it. + * ({"type":"turn-settled"} + [DONE]): the client refetches the record. + * 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 * 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; if (!getChat(id)) return Response.json({ error: "Unknown chat" }, { status: 404 }); const handle = runningTurn(id); - if (!handle) return settledTurnResponse(); + if (!handle) return settledTurnResponse(id); return piTurnResponse(handle); } diff --git a/app/api/settings/[section]/route.ts b/app/api/settings/[section]/route.ts index 616db9f..2c955cf 100644 --- a/app/api/settings/[section]/route.ts +++ b/app/api/settings/[section]/route.ts @@ -5,12 +5,13 @@ * discipline, same as the per-chat and defaults routes); sections are * independent, so partial writes can never clobber each other. * - * section ∈ general | chat | agent | appearance | graph | compaction + * section ∈ general | chat | agent | appearance | graph | compaction | models | dictation * Body: the complete desired section object. Parse + validate strictly * (server/settings-body.ts): a bad shape/type is a 400 carrying the * supported list where one exists. The chat and models sections * 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 * level CLAMPS to the named model's nearest supported level and 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) { 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; // 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 }); } } - // Model slots wave (2026-09-20): the models section gets the same - // catalog gate the chat section gets — a slot must name a real - // provider+model and a thinking level it supports. + // New/replaced slot models pass the catalog gate. Unchanged saved model + // references may survive disconnect, including thinking-preference edits, + // so each unavailable slot can be repaired independently of the others. if (section === "models") { - const verdict = validateModelsSettings(parsed.settings as ModelsSettings); + const verdict = validateModelsSettings(parsed.settings as ModelsSettings, record.models); if (!verdict.ok) { 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 // along untouched (other sections, unknown keys — sanitize preserves). - const record = readSettingsRecord() ?? { version: 1 }; const next = { ...record, [section]: sectionOut }; writeSettingsRecord(next); return Response.json({ settings: next }); diff --git a/app/api/subscriptions/browser/route.ts b/app/api/subscriptions/browser/route.ts new file mode 100644 index 0000000..760380f --- /dev/null +++ b/app/api/subscriptions/browser/route.ts @@ -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; diff --git a/app/api/subscriptions/route.ts b/app/api/subscriptions/route.ts new file mode 100644 index 0000000..3674db5 --- /dev/null +++ b/app/api/subscriptions/route.ts @@ -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; diff --git a/app/chat/[id]/page.tsx b/app/chat/[id]/page.tsx index 9f35d80..83413b3 100644 --- a/app/chat/[id]/page.tsx +++ b/app/chat/[id]/page.tsx @@ -1,7 +1,7 @@ import { notFound } from "next/navigation"; import { Chat } from "@/components/chat"; 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"; @@ -29,6 +29,7 @@ export default async function ChatPage({ // derived app-side (core/view.ts projectView; GET serves the same // native messages). initialMessages={chat.messages} + initialError={turnFailure(chat.id)} initialConfigured={slotsConfigured(record?.models)} initialPrompt={prompt} // A teacher turn that outlived its client: the remounted view must diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx index e4b533b..9e22382 100644 --- a/components/ai-elements/message.tsx +++ b/components/ai-elements/message.tsx @@ -34,7 +34,7 @@ import { type PluginConfig, } from "streamdown"; import { mdComponents } from "@/components/md-blocks"; -import { transformMathDelimiters } from "@/src/core"; +import { repairMathForDisplay, transformMathDelimiters } from "@/src/core"; export type MessageProps = HTMLAttributes & { from: LhViewMessage["role"]; @@ -388,6 +388,16 @@ export const MessageResponse = memo( 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 ( + + {typeof children === "string" ? repairMathForDisplay(children) : children} + + ); +} + export type MessageToolbarProps = ComponentProps<"div">; export const MessageToolbar = ({ diff --git a/components/answered-card.tsx b/components/answered-card.tsx index f281e33..ce76b80 100644 --- a/components/answered-card.tsx +++ b/components/answered-card.tsx @@ -5,9 +5,9 @@ import { ConfidenceRail } from "@/components/confidence-rail"; import { GlyphMark } from "@/components/ui/glyph-mark"; import { ThinkingBlock } from "@/components/thinking-block"; 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 { parseAskInput, parseQuizInput } from "@/src/core"; +import { answerForPresentation, parseAskInput, parseQuizInput } from "@/src/core"; /** * 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 * 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) if (answer.dismissed) { return ( @@ -86,13 +87,13 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in ))}
- {answer.questionText} + {answer.questionText}
{parsedDetails && ( // The details line, same paint as the pending dock (owner, // 2026-10-12): full markdown, LaTeX included.
- {parsedDetails} + {parsedDetails}
)} @@ -131,14 +132,14 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in )} - {opt.label} + {opt.label} {descByIndex.get(opt.index) && ( // Ask-option subtext (owner, 2026-10-12): same markdown path // as the pending dock — LaTeX included. Quiz options carry // none (owner ruling 2026-09-26); the map stays empty for them. - + {descByIndex.get(opt.index)} - + )} @@ -211,9 +212,9 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in {whyOpen && (
Why: - {/* The markdown wrapper must go truly inline — a block

after the - "Why:" label created the oversized leading gap (owner's fix list). */} - {answer.explanation} + {/* Keep the first paragraph beside the label, not every paragraph: + later paragraphs must retain their recovered document structure. */} + {answer.explanation}

)} @@ -225,7 +226,7 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in {isExplain && answer.committed && (
The answer: - {answer.committed} + {answer.committed}
)} diff --git a/components/artifacts/dispatch-card.tsx b/components/artifacts/dispatch-card.tsx index eaa6db7..982f284 100644 --- a/components/artifacts/dispatch-card.tsx +++ b/components/artifacts/dispatch-card.tsx @@ -15,7 +15,7 @@ import { BrailleSpinner } from "@/components/braille-spinner"; * * job unknown → "dispatched" pulse (the jobs feed hasn't seen it yet) * 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 * box must not duplicate it. */ @@ -54,7 +54,7 @@ export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: J > {failed ? : } {label} - {failed ? "failed" : "delivered"} + {failed ? "failed" : "finished"} ); } diff --git a/components/artifacts/svg-image.tsx b/components/artifacts/svg-image.tsx new file mode 100644 index 0000000..6c74cdd --- /dev/null +++ b/components/artifacts/svg-image.tsx @@ -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]*?(?:\?>|$)|<\/?[a-zA-Z](?:[^<>"']|"[^"]*"|'[^']*')*>| /g, + (token) => { + if (token.startsWith("|"deliver(steer) — injectable seam"| ENQ["enqueue steer in per-chat queue (globalThis)"] - ENQ --> PUMP["pump(chatId) — one loop per chat (pumping set serializes)"] - PUMP --> GATE{"turn-free AND no pending question? (400ms poll)"} - GATE -->|no| PUMP - GATE -->|yes| REFRESH["re-fetch fresh record, re-check steerDelivered (claim race)"] - REFRESH --> RUN["turnRunner(chatId, record + steer) — the SAME handleChatRequest path"] - RUN -->|"200"| FIN["finalizeSteer → finalizeJob (done/error)
— status flips only after the wake turn persists"] - RUN -->|"409"| BACKOFF["backoff 500→1000→2000→5000ms → retry"] - CRASH["crash recovery: listJobs → recoverStaleJobs (stale >15min)
re-deliver results / respawn via setJobRespawn"] --> PUMP + R["save worker outcome + pending delivery; release worker"] --> ENQ["one registry entry per steer ID, including wake cargo"] + ENQ --> LIVE{"live agent and background consent?"} + LIVE -->|yes| STEER["pi steering at native boundaries; answer run takes parked results"] + LIVE -->|no| GATE["wait for turn-free, question-free, unpaused, consented session"] + GATE --> RUN["idle wake through the normal turn composition"] + RUN -->|"409"| BACKOFF["existing backoff 500→1000→2000→5000ms"] + BACKOFF --> GATE + RUN -->|"403"| HOLD["retain ownership until consent permits delivery"] + 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_`, `steer_probe_`), `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). @@ -423,9 +431,11 @@ The Electron main is a launcher + credential/security guard, never a host for te ### 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. diff --git a/docs/compaction-accounting.md b/docs/compaction-accounting.md index f3b20d6..b2abcdc 100644 --- a/docs/compaction-accounting.md +++ b/docs/compaction-accounting.md @@ -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 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 The numerator is the latest qualifying completed conversation response's native diff --git a/docs/development.md b/docs/development.md index 062a72e..f775f61 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,13 +2,13 @@ The developer README (split out of the storefront README, 2026-10-10). Everything below is for people working ON LearnHarness, not using it. -A standalone learning app (web + Electron desktop): a **teacher agent** (Next.js; the Vercel AI SDK's `UIMessage` protocol end-to-end, LLM engine [pi-ai](docs/pi-engine-swap.md) via OpenRouter), probe→teach lessons with graded quiz/ask interactions, a formal **session plan tree** the teacher authors once (`plan_init`) and whose progress is derived from tagged quiz calls, and a persistent cross-session **knowledge graph** of what the learner knows — the graph is the app's home page. +A standalone learning app (web + Electron desktop): a **teacher agent** (Next.js; the Vercel AI SDK's `UIMessage` protocol end-to-end, LLM engine [pi-ai](pi-engine-swap.md) via OpenRouter), probe→teach lessons with graded quiz/ask interactions, a formal **session plan tree** the teacher authors once (`plan_init`) and whose progress is derived from tagged quiz calls, and a persistent cross-session **knowledge graph** of what the learner knows — the graph is the app's home page. Not a chat app with a graph bolted on: sessions are probe→teach lessons, the plan tree is a structured, schema-validated object, sessions accumulate into the graph through the extraction pipeline (propositions + reasoning chains — the stored truth; the canvas renders a derived image), and the teacher reaches back into the graph through read-only prober tools on its own judgment so probing compounds across sessions. -- [`docs/architecture.md`](docs/architecture.md) is the **canonical architecture spec** — layers, the import law, the five systems, the invariants, the seam directory. (The pre-audit spec is archived under `docs/archive/` — history only.) -- Living design doc (all decisions): [`docs/vision.md`](docs/vision.md) -- Build plan + wave/gate results: [`docs/plan.md`](docs/plan.md) +- [`docs/architecture.md`](architecture.md) is the **canonical architecture spec** — layers, the import law, the five systems, the invariants, the seam directory. (The pre-audit spec is archived under `docs/archive/` — history only.) +- Living design doc (all decisions): [`docs/vision.md`](vision.md) +- Build plan + wave/gate results: [`docs/plan.md`](plan.md) ## Commands @@ -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 run lint # eslint — includes the import law (architecture §2) 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:dist # local signed/notarized macOS arm64 build (LH_NOTARY keychain) @@ -32,7 +33,7 @@ npx tsx --env-file=.env.local scripts/backfill-graph.ts # wipe + re-extrac npx tsx --env-file=.env.local scripts/replay-layout.ts # re-derive layout from the ledgers ``` -For Windows/Linux packages and the three-OS CI/release flow, see [`desktop-release.md`](desktop-release.md). Native renderer binaries are traced per platform, so release builds run on the target OS, not as cross-builds from macOS. +For Windows/Linux packages and the three-OS CI/release flow, see [`docs/desktop-release.md`](desktop-release.md). Native renderer binaries are traced per platform, so release builds run on the target OS, not as cross-builds from macOS. ## Environment (`.env.local`) @@ -47,9 +48,19 @@ 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. +### 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 -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`](architecture.md). ```mermaid flowchart TB @@ -165,7 +176,7 @@ pricing-generation step: Pi AI's catalog and public calculator are the authority **Practice (problem sets).** A node selection resolves to the STORED reasoning chains it fully contains (a chain rides iff every member is selected; unchained nodes ride as singletons); the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — a full-bleed sortable table of every reasoning chain, grouped by birth cluster under the shared top bar, selecting chips, chains, or whole clusters, whose floating selection bar shows the exact `N nodes → M chains` the agents will receive — handing a free-form node selection to the same dialog — and carries the fenced Delete action, making it the graph's management view (whole-cluster cleanup through the same full-death confirm dialog). A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot. -**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. +**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. Settings → Providers → Dictation → Language (below the Deepgram key) offers Nova-3's documented monolingual languages/locales or `language=multi` for automatic multilingual transcription. The app-wide `dictation.language` preference persists through the existing settings record and `PUT /api/settings/dictation`; absent preferences resolve to English (`en`). The settings control locks from connecting through stopping, and reconnects keep the run's original language. A shared preference controller publishes confirmed PUT responses to every reader immediately; the confirmed preference is cached across page navigation and reopening Settings, with no polling or per-recording read. Explicit external dictation-settings notifications invalidate the cache, and capture waits for a successful refresh. Navigating away cancels the run, invalidates pending startup/reconnect work and releases its microphone/socket without appending into another page's input. All other streaming parameters remain unchanged. The picker uses the model dropdown's shared `SearchMenuList` and `MenuShell`: the same fuzzy search, arrow navigation, Enter selection, row styling and scrolling. Its compact mono `SettingsMenuTrigger` is shared with Agent and Appearance; a muted setup line below the key and language rows links the Deepgram Console and states the Member-or-higher key-role requirement. ## Testing @@ -179,10 +190,10 @@ All deterministic, no LLM, no network (engines are injected — pi-ai's faux pro ## Lineage -The teaching methodology comes from an Obsidian-based POC (its `knowledge graph design.md` was the original epistemic-model source, since amended — see the amendments table in `docs/vision.md`). The harness was originally built on the pi agent SDK, pivoted to standalone Next.js + the Vercel AI SDK, and later swapped its LLM engine back to pi-ai while keeping the AI SDK's `UIMessage` as the client protocol ([`docs/pi-engine-swap.md`](docs/pi-engine-swap.md)). +The teaching methodology comes from an Obsidian-based POC (its `knowledge graph design.md` was the original epistemic-model source, since amended — see the amendments table in `docs/vision.md`). The harness was originally built on the pi agent SDK, pivoted to standalone Next.js + the Vercel AI SDK, and later swapped its LLM engine back to pi-ai while keeping the AI SDK's `UIMessage` as the client protocol ([`docs/pi-engine-swap.md`](pi-engine-swap.md)). ## Data Nothing under `data/` is ever tracked by git. Chats, graph ledgers, jobs, positions, and the settings records are plain files (JSON/JSONL) — inspect them freely; they are the canonical records. The desktop app relocates the whole directory via `LH_DATA_DIR`. -**Schema stamps (2026-10-10):** chat records carry `version` (stamped at the store's write choke point; absence reads as v1 — lazy migration, no mass rewrite), and the graph family (ledgers, sources, render-settings) shares one sidecar `graph/format.json`, created on the family's first write. `positions.json` is deliberately unstamped (rebuildable). A record stamped NEWER than the running build is refused on read — honest refusal for the downgrade scenario: a read-modify-write store never rewrites what it doesn't understand. \ No newline at end of file +**Schema stamps (2026-10-10):** chat records carry `version` (stamped at the store's write choke point; absence reads as v1 — lazy migration, no mass rewrite), and the graph family (ledgers, sources, render-settings) shares one sidecar `graph/format.json`, created on the family's first write. `positions.json` is deliberately unstamped (rebuildable). A record stamped NEWER than the running build is refused on read — honest refusal for the downgrade scenario: a read-modify-write store never rewrites what it doesn't understand. diff --git a/docs/probe-teach-split.md b/docs/probe-teach-split.md index d1b1c96..7f54530 100644 --- a/docs/probe-teach-split.md +++ b/docs/probe-teach-split.md @@ -21,7 +21,8 @@ philosophy so they share a view of the whole process). field it must not use. No plan tools, no jobs, no demos. - **The teacher** runs everything from planning onward: full tool set (teach quiz with nodeId, plan_init, jobs, demos). Constructed the moment the - transcript carries a `probe_done`. + transcript carries a concluded `probe_done`: a non-failed/non-aborted + assistant call followed by a matching successful tool result. - **The swap happens between probing and planning** — the planning phase needs full knowledge of how teaching is done, so the teacher agent starts at planning. @@ -43,6 +44,12 @@ The prober concludes explicitly: - **Refuses while a question is open** ("resolve the open question first") — same doctrine as nodeId validation. - **Refuses a second conclusion** (idempotent: the probe concludes once). + Conclusion requires a later `probe_done` result matching the call's ID + and tool name. The calling generation must not have failed or aborted; + the result must not be `isError` or explicitly contain `details.ok:false`. + Clean acknowledgments without an explicit `ok:true` remain valid. Bare, + unmatched, errored, or refused calls keep probing open; saved attempts + are never deleted or replay-executed by this detection rule. - The assessment is the tool call's input, so it streams to the learner live and is durable in the transcript by the tool result itself. A dedicated assessment card/viewer comes later (UI). @@ -53,7 +60,7 @@ The prober concludes explicitly: - **Side effect: it queues the handoff.** When the prober's turn ends, the wake pipeline (the same machinery that delivers sub-agent results and abort steers) fires a server-side turn with a handoff steer — the teacher - is constructed (probe_done is in the transcript) and authors the plan. + is constructed (probe_done has successfully concluded) and authors the plan. The learner experiences assessment → plan as one continuous flow. If the wake is lost (crash window), the next client message still constructs the teacher — the transcript is the source of truth. diff --git a/docs/tool-content-recovery.md b/docs/tool-content-recovery.md new file mode 100644 index 0000000..8151b1a --- /dev/null +++ b/docs/tool-content-recovery.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+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. diff --git a/electron/main.cjs b/electron/main.cjs index 46db548..a853736 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -24,12 +24,13 @@ * 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 { attachChatGPTChild, trustedSender, chatGPTControl } = require("./chatgpt-ipc.cjs"); const { createSubscriptions, loadSubscriptionSDK } = require("./subscriptions.cjs"); const { attachSubscriptionChild, subscriptionControl } = require("./subscription-ipc.cjs"); const { desktopAPIHeaders } = require("./desktop-api.cjs"); +const { openWebSubscriptionBrowser } = require("./web-subscription-browser.cjs"); const { randomBytes } = require("node:crypto"); const { spawn } = require("node:child_process"); const fs = require("node:fs"); @@ -140,6 +141,7 @@ let subscriptions = null; let subscriptionChild = null; // Per-launch request capability, never OAuth tokens and never renderer state. let desktopAPISecret = null; +let reopenWindow = null; const trustedAppWindows = new Set(); // A GUI shell must never die to a stray exception (EPIPE dialogs, a race @@ -314,6 +316,7 @@ function createWindow(url) { contextIsolation: true, sandbox: true, nodeIntegration: false, + ...(process.env.LH_DEV_URL ? { additionalArguments: ["--lh-web-subscriptions"] } : {}), preload: path.join(__dirname, "preload.cjs"), }, // 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. */ const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; 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) { - for (const win of BrowserWindow.getAllWindows()) { - try { - win.webContents.send("lh:update-status", status); - } catch { - /* window mid-teardown — the next push retries */ +function reportUpdateStatus(status, version = null) { + try { + // A later check/error must not hide an update already staged for quit. + if (updateState.status === "downloaded" && status !== "downloaded") return; + updateState = { status, version: typeof version === "string" ? version : null, revision: updateState.revision + 1 }; + 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 } = {}) { 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 .checkForUpdates() .then((result) => { - // The download itself is silent (autoDownload); the banner fires on - // update-downloaded. Manual checks additionally answer directly. - if (!manual) return; - 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.` }); + // A declined check (e.g. an unsupported package) establishes no + // latest-version claim. Real results arrive through updater events. + if (!result) reportUpdateStatus("idle"); }) .catch((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() { if (!app.isPackaged) { log("auto-update: dev build — updater disabled"); + reportUpdateStatus("disabled"); return; } try { autoUpdater = require("electron-updater").autoUpdater; } catch (err) { log(`auto-update: electron-updater unavailable (${err?.message ?? err}) — updates disabled`); + reportUpdateStatus("disabled"); return; } const feed = process.env.LH_UPDATE_FEED; @@ -500,11 +531,37 @@ function setupAutoUpdater() { debug: (m) => {}, // too chatty for the persistent log 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) => { 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) { @@ -530,9 +587,8 @@ function installMenu() { label: app.name, submenu: [ { role: "about" }, - // Manual check (Wave C): answers via native dialog — no renderer - // round-trip. The 24h automatic checks stay silent. - { label: "Check for Updates…", click: () => checkForUpdates({ manual: true }) }, + // Manual checks report their result in Settings → General. + { label: "Check for Updates…", click: () => checkForUpdatesFromMenu() }, { type: "separator" }, { role: "hide" }, { role: "hideOthers" }, @@ -646,6 +702,13 @@ async function main() { 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(); // 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. setupAutoUpdater(); 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 // the sidebar below SIDEBAR_AUTOCLOSE (928px); opening it again at that @@ -670,13 +741,14 @@ async function main() { const target = SIDEBAR_AUTOCLOSE; if (width < target) win.setBounds({ x, y, width: target, height }); }); + reopenWindow = () => { chatgpt?.resume(); subscriptions?.resume(); return createWindow(url); }; app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { chatgpt?.resume(); subscriptions?.resume(); createWindow(url); } + if (BrowserWindow.getAllWindows().length === 0) reopenWindow(); }); createWindow(url); log(`window open: ${url}`); - // First update check AFTER the window exists (the downloaded-banner - // push needs a listener); dev builds no-op (no autoUpdater). + // Startup + 24h checks stay main-owned and read the current preference. + // No renderer subscription is required; late loads retrieve the snapshot. if (autoUpdater) { checkForUpdates(); setInterval(() => checkForUpdates(), UPDATE_CHECK_INTERVAL_MS); diff --git a/electron/preload.cjs b/electron/preload.cjs index 4912005..a07fe74 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -19,16 +19,31 @@ * * window.lh.onUpdateStatus(cb) / window.lh.installUpdate() — the * auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes - * {status:"downloaded", version} once an update is staged; installUpdate - * asks main to kill the server and quit-and-install. Browser builds: - * both absent, the banner never renders. + * {status, version, revision} on updater events; installUpdate asks main + * to kill the server and quit-and-install. getUpdateState retrieves the + * 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. */ 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", { platform: process.platform, + ...(process.argv?.includes("--lh-web-subscriptions") ? { + subscriptionTransport: "web", + openSubscriptionBrowser: (action) => ipcRenderer.invoke("lh:subscriptions:web-browser", action), + } : {}), chatgpt: { status: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "status" }), login: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "login", id }), @@ -71,4 +86,17 @@ contextBridge.exposeInMainWorld("lh", { return () => ipcRenderer.removeListener("lh:update-status", listener); }, 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; + }; + }, }); diff --git a/electron/web-subscription-browser.cjs b/electron/web-subscription-browser.cjs new file mode 100644 index 0000000..b61c73b --- /dev/null +++ b/electron/web-subscription-browser.cjs @@ -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 }; diff --git a/next.config.ts b/next.config.ts index db9352e..a4236ad 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,11 @@ +import { readFileSync } from "node:fs"; 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 = { + // 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 // self-contained .next/standalone/server.js the Electron shell spawns // on 127.0.0.1. Harmless for plain web deploys (extra build output). @@ -43,6 +48,9 @@ const nextConfig: NextConfig = { "*": [ "**/data/**/*", "auth.json", + "chatgpt-credentials.json*", + "github-copilot-credentials.json*", + "xai-credentials.json*", ".env*", ], }, diff --git a/package-lock.json b/package-lock.json index ec2e25e..59820df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "learnharness", - "version": "0.3.7", + "version": "0.3.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "learnharness", - "version": "0.3.7", + "version": "0.3.9", "license": "AGPL-3.0-only", "dependencies": { "@earendil-works/pi-agent-core": "1.4.0", @@ -36,7 +36,10 @@ "katex": "0.18.2", "linkedom": "^0.18.13", "lucide-react": "^1.33.0", + "mdast-util-from-markdown": "2.0.3", + "mdast-util-math": "3.0.0", "media-chrome": "^4.19.2", + "micromark-extension-math": "3.1.0", "mermaid": "^11.17.0", "motion": "^13.1.1", "nanoid": "^6.0.1", @@ -66,7 +69,7 @@ "@types/turndown": "^5.0.6", "@typescript-eslint/parser": "^8.68.0", "concurrently": "^10.0.5", - "electron": "^44.4.1", + "electron": "^44.9.3", "electron-builder": "26.16.1", "eslint": "^9.39.5", "eslint-import-resolver-typescript": "^4.4.5", @@ -8969,9 +8972,9 @@ } }, "node_modules/electron": { - "version": "44.4.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-44.4.1.tgz", - "integrity": "sha512-gXJarQ2Utfn8G6bp/IqLXvckOSCtrxPWjFqQaqm02Gf4zYn1aP+RN9PKimoLH/DMqjKLfBrNC4C4CviPv0cXnQ==", + "version": "44.9.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.9.3.tgz", + "integrity": "sha512-Cv9/yMnZxNWsMlIe8T4v//NKHdUvbeyoTJoXE3BIcRxus2meeFaO9h/P3zB5d4cBXum51IgAYrT2QjpIDWKpJA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 0371a90..9da0caf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "learnharness", - "version": "0.3.7", + "version": "0.3.9", "license": "AGPL-3.0-only", "author": { "name": "Amos Blomqvist", @@ -51,7 +51,10 @@ "katex": "0.18.2", "linkedom": "^0.18.13", "lucide-react": "^1.33.0", + "mdast-util-from-markdown": "2.0.3", + "mdast-util-math": "3.0.0", "media-chrome": "^4.19.2", + "micromark-extension-math": "3.1.0", "mermaid": "^11.17.0", "motion": "^13.1.1", "nanoid": "^6.0.1", @@ -84,7 +87,7 @@ "@types/turndown": "^5.0.6", "@typescript-eslint/parser": "^8.68.0", "concurrently": "^10.0.5", - "electron": "^44.4.1", + "electron": "^44.9.3", "electron-builder": "26.16.1", "eslint": "^9.39.5", "eslint-import-resolver-typescript": "^4.4.5", diff --git a/scripts/build-server.mjs b/scripts/build-server.mjs index bbb1480..266ff75 100644 --- a/scripts/build-server.mjs +++ b/scripts/build-server.mjs @@ -52,7 +52,8 @@ if (exists(leakedData)) { // (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 // 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))) { console.error(`[build-server] REFUSING: standalone contains ${secret} — dev secrets would ship in the app.`); console.error(" Check outputFileTracingExcludes in next.config.ts."); diff --git a/scripts/smoke-opening-prompt.mjs b/scripts/smoke-opening-prompt.mjs new file mode 100644 index 0000000..c1f4665 --- /dev/null +++ b/scripts/smoke-opening-prompt.mjs @@ -0,0 +1,251 @@ +/** Focused synthetic live-UI check. No model/provider calls: /api/chat is + * intercepted, catalog is a fixture, app HOME/data/auth are throwaway, and + * the browser uses a fresh profile. Creates only synthetic session records. + * Run: node scripts/smoke-opening-prompt.mjs + */ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import fs from "node:fs"; +import net from "node:net"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import puppeteer from "puppeteer-core"; + +const root = path.resolve(import.meta.dirname, ".."); +const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh154-ui-")); +fs.mkdirSync(path.join(home, "data")); +fs.writeFileSync(path.join(home, "auth.json"), "{}\n"); +// Next's supported font fixture avoids external font downloads in this check. +const require = createRequire(import.meta.url); +const { getFontAxes } = require("next/dist/compiled/@next/font/dist/google/get-font-axes.js"); +const { getGoogleFontsUrl } = require("next/dist/compiled/@next/font/dist/google/get-google-fonts-url.js"); +const fonts = Object.fromEntries(["JetBrains Mono", "Source Sans 3"].map(family => [ + getGoogleFontsUrl(family, getFontAxes(family, ["variable"], ["normal"]), "swap"), + `@font-face { font-family: '${family}'; src: local('Arial'); font-weight: 100 900; }`, +])); +fs.writeFileSync(path.join(home, "fonts.cjs"), `module.exports = ${JSON.stringify(fonts)};\n`); +const listener = net.createServer(); +listener.listen(0, "127.0.0.1"); +await once(listener, "listening"); +const port = listener.address().port; +await new Promise(resolve => listener.close(resolve)); +const origin = `http://127.0.0.1:${port}`; +const env = { + PATH: process.env.PATH, HOME: home, TMPDIR: home, NODE_ENV: "development", + LH_DATA_DIR: path.join(home, "data"), LH_AUTH_FILE: path.join(home, "auth.json"), + LH_IGNORE_ENV_KEYS: "1", NEXT_TELEMETRY_DISABLED: "1", + NEXT_FONT_GOOGLE_MOCKED_RESPONSES: path.join(home, "fonts.cjs"), +}; +const server = spawn(process.execPath, ["node_modules/next/dist/bin/next", "dev", "--hostname", "127.0.0.1", "--port", String(port)], { + cwd: root, env, stdio: ["ignore", "pipe", "pipe"], +}); +let logs = ""; +const capture = data => { logs = (logs + data).slice(-24000); }; +server.stdout.on("data", capture); server.stderr.on("data", capture); +let browser; +let page; +const errors = []; +const checks = []; +try { + await new Promise((resolve, reject) => { + const deadline = setTimeout(() => reject(new Error("app startup timed out")), 60000); + const ready = () => { if (logs.includes("Ready in")) { clearTimeout(deadline); server.stdout.off("data", ready); resolve(); } }; + server.stdout.on("data", ready); + server.once("exit", code => { clearTimeout(deadline); reject(new Error(`app exited ${code}`)); }); + ready(); + }); + browser = await puppeteer.launch({ + executablePath: process.env.LH_TEST_CHROME || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + headless: true, userDataDir: path.join(home, "browser"), env, + args: ["--disable-background-networking", "--disable-sync", "--no-first-run", "--disable-default-apps"], + }); + page = await browser.newPage(); + page.setDefaultTimeout(45000); + page.on("pageerror", err => errors.push(err.message)); + const model = { id: "synthetic", name: "Synthetic (no calls)", provider: "fixture", input: ["text"], reasoning: false, supportedLevels: ["off"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024 }; + const slot = { model: { provider: model.provider, id: model.id }, thinkingLevel: "off" }; + const catalog = { models: [model], providers: [{ id: "fixture", label: "Fixture", keyPresent: true }], defaults: null, + slots: { strong: slot, cheap: slot, vision: slot }, rolePins: {} }; + const posts = []; + const creates = []; + const accepted = new Map(); + let mode = "accepted"; + let held = []; + await page.setRequestInterception(true); + page.on("request", async request => { + try { + const url = new URL(request.url()); + if (url.origin !== origin) { await request.abort(); return; } + const pathname = url.pathname; + const respond = body => request.respond({ status: 200, contentType: "application/json", body: JSON.stringify(body) }); + if (pathname === "/api/models") return await respond(catalog); + // Notifications are outside this handover check. 204 tells native + // EventSource not to reconnect or hold a test connection open. + if (pathname === "/api/events") return await request.respond({ status: 204 }); + if (pathname === "/api/chat") { + const body = JSON.parse(request.postData()); posts.push(body); + if (mode === "held-post") { held.push(request); return; } + if (mode === "lost-ack") return await request.abort("failed"); + if (mode === "refused") return await request.respond({ status: 503, contentType: "application/json", body: JSON.stringify({ code: "models_not_configured", missing: ["strong"] }) }); + accepted.set(body.chatId, [{ id: "saved_user", role: "user", content: body.input.text, timestamp: Date.now() }]); + return await respond({ ok: true }); + } + if (/^\/api\/chats\/[^/]+\/stream$/.test(pathname)) { + // Keep teacher delivery unfinished: cleanup must NOT wait for it. + held.push(request); return; + } + const match = pathname.match(/^\/api\/chats\/([^/]+)$/); + if (match && request.method() === "GET" && accepted.has(match[1])) { + return await respond({ messages: accepted.get(match[1]), turns: [], turnInFlight: false }); + } + if (pathname === "/api/chats" && request.method() === "POST") creates.push(JSON.parse(request.postData())); + await request.continue(); + } catch (err) { if (!/Target closed|Session closed|Invalid InterceptionId/.test(String(err))) errors.push(String(err)); } + }); + async function open(route) { + // CDP-intercepted requests do not settle themselves on navigation. + // Release synthetic streams from prior cases rather than pin slots. + await Promise.all(held.splice(0).map(request => request.abort("aborted").catch(() => {}))); + await page.goto(origin + route, { waitUntil: "domcontentloaded", timeout: 120000 }); + await page.waitForSelector(route === "/" ? "textarea" : '[data-testid="composer"] textarea'); + await page.waitForFunction(() => !document.querySelector('[data-testid="setup-cta"]') && !document.querySelector('[data-testid="setup-cta-keys"]')); + } + async function fill(text) { + await page.evaluate(value => { + const textarea = document.querySelector('[data-testid="composer"] textarea') || document.querySelector("textarea"); + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value").set.call(textarea, value); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }, text); + } + async function submit() { + await page.waitForFunction(() => { + const button = document.querySelector('button[aria-label="Start session"], [data-testid="composer"] button[aria-label="Send"]'); + return button && !button.disabled; + }); + await page.locator('button[aria-label="Start session"], [data-testid="composer"] button[aria-label="Send"]').click(); + } + async function stash(id) { return page.evaluate(chat => sessionStorage.getItem(`lh.openingPrompt.${chat}`), id); } + async function assertDraft(text) { await page.waitForFunction(value => document.querySelector('[data-testid="composer"] textarea')?.value === value, {}, text); } + const long = "A full synthetic syllabus — 世界 & ? #. ".repeat(1200).trim(); + for (const route of ["/", "/chat/new"]) { + for (const text of ["a short synthetic topic", long]) { + await open(route); + const count = posts.length; + await fill(text); await submit(); + await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname) && !location.search); + await page.waitForFunction(() => document.querySelector('[data-testid="composer"]')); + await page.waitForFunction(() => !Object.keys(sessionStorage).some(k => k.startsWith("lh.openingPrompt."))); + assert.equal(posts.length, count + 1); + assert.equal(posts.at(-1).input.text, text); + assert.equal(new URL(page.url()).search, ""); + assert.equal(await stash(posts.at(-1).chatId), null); + checks.push(`${route}: ${text.length > 1000 ? "long" : "short"} input delivered intact through short URL; stash removed while stream held`); + } + } + // The old long-query mechanism really is rejected by this isolated Node server. + const oldResponse = await fetch(`${origin}${new URL(page.url()).pathname}?prompt=${encodeURIComponent(long)}`); + assert.equal(oldResponse.status, 431); await oldResponse.text(); + checks.push("old long-query request returned431 (browser-shell blank appearance not asserted)"); + + for (const route of ["/", "/chat/new"]) { + await open(route); + await page.evaluate(() => { + const setItem = Storage.prototype.setItem; + Storage.prototype.setItem = function(key, value) { + if (window.failOpeningStorage && key.startsWith("lh.openingPrompt.")) throw new DOMException("synthetic quota", "QuotaExceededError"); + return setItem.call(this, key, value); + }; + window.failOpeningStorage = true; + }); + const createCount = creates.length; const sendCount = posts.length; + await fill(long); await submit(); + await page.waitForSelector('[role="alert"]'); + assert.equal(new URL(page.url()).pathname, route); + assert.equal(await page.$eval("textarea", element => element.value), long); + assert.equal(posts.length, sendCount); + assert.equal(creates.length, createCount + 1); + await page.evaluate(() => { window.failOpeningStorage = false; }); + await submit(); + await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname)); + await page.waitForFunction(() => !Object.keys(sessionStorage).some(k => k.startsWith("lh.openingPrompt."))); + assert.equal(creates.length, createCount + 1, "unchanged retry reuses creation"); + assert.equal(posts.at(-1).input.text, long); + checks.push(`${route}: quota failure preserved visible draft/no navigation; retry reused creation`); + } + + for (const failure of ["lost-ack", "refused", "held-post"]) { + mode = failure; + await open("/chat/new"); + const text = `recover interrupted ${failure}`; + await fill(text); await submit(); + await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname)); + await page.waitForFunction(() => Object.values(sessionStorage).some(v => v.includes('"attempted":true'))); + if (failure !== "held-post") await assertDraft(text); + const count = posts.length; + await page.reload({ waitUntil: "domcontentloaded" }); + await assertDraft(text); + assert.equal(posts.length, count, "interrupted start must not automatically repost"); + checks.push(`${failure}: reload restored full draft, no automatic repost`); + if (failure === "lost-ack") { + mode = "accepted"; + await submit(); + const id = new URL(page.url()).pathname.split("/").at(-1); + await page.waitForFunction(chat => !sessionStorage.getItem(`lh.openingPrompt.${chat}`), {}, id); + assert.equal(posts.length, count + 1); + assert.equal(posts.at(-1).input.text, text); + checks.push("restored draft sent only after explicit click and cleaned up on acceptance"); + } + } + mode = "accepted"; + // Marker-only session must still automatically deliver a fresh opening. + await open("/chat/new"); + const markerId = await page.evaluate(async () => { + const res = await fetch("/api/chats", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "marker fixture", graphContext: { propositions: [{ id: "synthetic_node", name: "Fixture", statement: "Synthetic" }] } }) }); + return (await res.json()).id; + }); + await page.evaluate(id => sessionStorage.setItem(`lh.openingPrompt.${id}`, JSON.stringify({ text: "marker opening", attempted: false })), markerId); + const markerCount = posts.length; + await page.goto(`${origin}/chat/${markerId}`, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(id => !sessionStorage.getItem(`lh.openingPrompt.${id}`), {}, markerId); + assert.equal(posts.length, markerCount + 1); + assert.equal(posts.at(-1).input.text, "marker opening"); + checks.push("graph-marker-only record still receives opening text"); + // A real persisted user prevents delivery and cleans a stale copy on mount. + const chatFile = path.join(home, "data", "chats", `${markerId}.json`); + const record = JSON.parse(fs.readFileSync(chatFile, "utf8")); + record.messages.push({ id: "confirmed_fixture", role: "user", content: "already received", timestamp: Date.now() }); + fs.writeFileSync(chatFile, JSON.stringify(record)); + await page.evaluate(id => sessionStorage.setItem(`lh.openingPrompt.${id}`, JSON.stringify({ text: "stale opening", attempted: true })), markerId); + const completedCount = posts.length; + await page.reload({ waitUntil: "domcontentloaded" }); + await page.waitForFunction(id => !sessionStorage.getItem(`lh.openingPrompt.${id}`), {}, markerId); + assert.equal(posts.length, completedCount); + checks.push("persisted real conversation removed stale stash without reposting"); + await open("/chat/new"); + const legacyId = await page.evaluate(async () => { + const response = await fetch("/api/chats", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "legacy fixture" }) }); + return (await response.json()).id; + }); + const legacyCount = posts.length; + await page.goto(`${origin}/chat/${legacyId}?prompt=${encodeURIComponent("legacy opening")}`, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => document.body.innerText.includes("legacy opening")); + await page.waitForFunction(id => !sessionStorage.getItem(`lh.openingPrompt.${id}`), {}, legacyId); + assert.equal(posts.length, legacyCount + 1); + assert.equal(posts.at(-1).input.text, "legacy opening"); + checks.push("legacy short ?prompt link still sends its complete opening message"); + assert.deepEqual(errors, []); + console.log(JSON.stringify({ checks, pageErrors: errors, isolation: "synthetic catalog/POST+streams; empty auth; no providers", origin }, null, 2)); +} catch (err) { + console.error(err); + console.error(JSON.stringify({ checks, errors, url: page?.url(), dom: await page?.evaluate(() => ({ textareas: [...document.querySelectorAll('textarea')].map(e => ({ length: e.value.length, placeholder: e.placeholder })), buttons: [...document.querySelectorAll('button[aria-label="Send"], button[aria-label="Start session"]')].map(e => ({ disabled: e.disabled, label: e.getAttribute('aria-label') })), alerts: [...document.querySelectorAll('[role="alert"]')].map(e => e.textContent), failStorage: window.failOpeningStorage, text: document.body.innerText.slice(-1200) })).catch(() => null) }, null, 2)); + console.error(logs); process.exitCode = 1; +} finally { + await browser?.close(); + const exited = once(server, "exit"); + server.kill("SIGTERM"); + const killTimer = setTimeout(() => server.kill("SIGKILL"), 5000); killTimer.unref(); + await exited; clearTimeout(killTimer); + fs.rmSync(home, { recursive: true, force: true }); +} diff --git a/scripts/smoke-web-subscriptions.mjs b/scripts/smoke-web-subscriptions.mjs new file mode 100644 index 0000000..9918b65 --- /dev/null +++ b/scripts/smoke-web-subscriptions.mjs @@ -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 }); } diff --git a/src/agent/chatgpt-bridge.ts b/src/agent/chatgpt-bridge.ts index 5412549..53d748d 100644 --- a/src/agent/chatgpt-bridge.ts +++ b/src/agent/chatgpt-bridge.ts @@ -1,4 +1,5 @@ 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 { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.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 { 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 { const global = globalThis as typeof globalThis & { [key]?: BridgeState }; 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 }; 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 }; - 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) { acceptStatus(state, { ...m.stamp, status: m.status }); if (m.invalidate === "all") { state.catalogRequest++; clearCatalog(state); } @@ -43,8 +45,11 @@ function bridge(): BridgeState { if (m.ok === true) call.resolve(m.value); 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", () => { + if (webSubscriptionsEnabled()) return; state.status = unavailable(); clearCatalog(state); state.catalogRequest++; 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.")); } @@ -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); 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(op: "status" | "models" | "lease", fields: Record = {}): Promise { const state = bridge(); + if (webSubscriptionsEnabled()) return webSubscriptionRequest("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.")); return new Promise((resolve, reject) => { const id = `${process.pid}:${++state.counter}`; @@ -74,7 +80,7 @@ export function chatGPTRequest(op: "status" | "models" | "lease", fields: Rec }); } 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 onChatGPTChange(listener: () => void): () => void { bridge().listeners.add(listener); return () => { bridge().listeners.delete(listener); }; } 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 { 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 { 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. */ export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise { const state = bridge(); - if (!desktopIPCConnected()) { clearCatalog(state); return; } + if (!runtimeConnected()) { clearCatalog(state); return; } const sequence = ++state.catalogRequest; try { const value = await chatGPTRequest("models"); diff --git a/src/agent/subagents/aborts.ts b/src/agent/subagents/aborts.ts index ed5b587..b533b8a 100644 --- a/src/agent/subagents/aborts.ts +++ b/src/agent/subagents/aborts.ts @@ -41,18 +41,16 @@ export function unregisterJobAbort(jobId: string): void { * 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 * 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 - * loop-start to delivery-completion: the runner's unregistering finally - * runs only after deliver() resolves, by which point finalizeJob has - * flipped the record — so absence of registration is unambiguous. */ + * an orphan of a process death and recovers immediately. The registration + * spans computation and cleanup only. Finished outcomes have independent, + * persisted delivery obligations; no worker waits for their consumption. */ export function isJobLive(jobId: string): boolean { return registry().has(jobId); } /** 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 - * phase where the runner has already returned); the caller then owns the - * whole abort on the record side. */ + * the job's loop is not executing. The endpoint separately refuses abort + * for a finished worker, even while its session delivery remains pending. */ export function fireJobAbort(jobId: string): boolean { const controller = registry().get(jobId); if (!controller) return false; diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts index aca547f..24cc975 100644 --- a/src/agent/subagents/extraction.ts +++ b/src/agent/subagents/extraction.ts @@ -28,7 +28,7 @@ import type { GraphStore } from "../../core/ports.ts"; import { emit } from "../../core/events.ts"; import { settleGraphLayout } from "../../core/layout-settle.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 { 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): - * ONLY the teacher agent's text blocks — no learner messages, no - * thinking, no tool calls or results, no quiz blocks — each labeled with + * ONLY learner-facing teacher prose (text or send_to_user) — no learner + * messages, thinking, other tool calls/results, or quiz blocks — labeled with * its ACTUAL message id (owner, 2026-10-13): the view lines ARE * core/coverage.ts messageViewLines ("TEACHER [id] text"), the exact * lines the coverage fingerprints hash, so the extractor can anchor * every proposition to the passage it came from (the session submit's * required messageId). The shared markdown engine stays reserved for the * future user-facing export. */ -function proseView(messages: ReadonlyArray, title?: string): string { - const lines = messages.flatMap((message) => messageViewLines(message, message.id)); +function proseView( + messages: ReadonlyArray, + title?: string, + failedOutputCalls: ReadonlySet = failedOutputCallIds(messages), +): string { + const lines = messages.flatMap((message) => messageViewLines(message, message.id, failedOutputCalls)); const head = title && title.length > 0 ? `# ${title}\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). refuseIfStagesUnready(); const covered = coverageOf(record.messages, graphStore.readCoverage(), chatId); + const failedOutputCalls = failedOutputCallIds(record.messages); if (covered.uncovered.length === 0) { console.info(`[extraction ${chatId}] nothing uncovered — no-op`); return { nodes: 0, chains: 0 }; @@ -1148,14 +1153,14 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti // fingerprints' messageViewLines ("TEACHER [id] text"), one // shared seam. 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 // job's submit may name (validateSessionAnchors at the // submit seam refuses anything else: ids from other // batches — even real ones of this chat — other chats, // and non-prose messages). 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); const findings = await withExtractSlot(jobKey, async () => { @@ -1188,7 +1193,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti const batchStats = mergeFindings(chatId, findings, auditResult, { chatStore, graphStore }, { foldedAt, 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 })), }); // The layout ledger evolves with the merge (sync — the diff --git a/src/agent/subagents/pdf-reader.ts b/src/agent/subagents/pdf-reader.ts index 462a3e3..c3feae2 100644 --- a/src/agent/subagents/pdf-reader.ts +++ b/src/agent/subagents/pdf-reader.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai"; import { normalizeContext } from "@earendil-works/pi-ai"; import { Type } from "typebox"; +import { resolveSourcePath } from "../tools/fs-shared.ts"; import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts"; import { providerEventObserver, type ProviderEventObserver } from "../provider-events.ts"; import type { PdfResult, PdfToolInput } from "../../core/contracts.ts"; @@ -166,7 +167,7 @@ export async function runPdfReaderAgent( ): Promise { const headless = opts.headless ?? getHeadless(); const maxIterations = opts.maxIterations ?? MAX_ITERATIONS; - const pdfPath = input.path; + const pdfPath = resolveSourcePath(input.path); // Deterministic upfront failure: unreadable / unparseable // for renders — the brief never starts on a broken source. diff --git a/src/agent/subagents/runner.ts b/src/agent/subagents/runner.ts index 41a3d40..55651dc 100644 --- a/src/agent/subagents/runner.ts +++ b/src/agent/subagents/runner.ts @@ -3,6 +3,7 @@ import type { SearchProvider } from "../../core/search.ts"; import { accumulateJobUsage, buildSubagentSteer, + isSubagentFailure, piUsageToStep, type DemoResult, 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), * updating the job's activity line on every iteration. On completion the * runner builds the STEER MESSAGE (buildSubagentSteer) and hands it to - * `deliver`; the wake pipeline fires the server-side teacher turn and - * flips the job to done/error when the lesson has actually resumed. + * `deliver` without waiting. The worker's final outcome and pending + * delivery are saved together; the session owns delivery from then on. * * On failure the result is the failure shape (empty html/svg + "FAILED: …" * notes; research: failure brief) — and it STILL steers, because the - * teacher must learn the outcome. If delivery itself fails, the job is - * marked error (the learner's activity card shows it). + * teacher must learn the outcome. Failed delivery starts stay quietly + * pending and never rewrite the worker's computation outcome. */ /** @@ -67,9 +68,9 @@ export interface JobRunnerDeps { chatStore: ChatStore; jobStore: JobStore; /** - * Hand a finished job's steer to the wake pipeline — resolves when the - * steer is delivered (the wake turn completed and the job status - * flipped); rejects when delivery fails unrecoverably. + * Hand a finished job's steer to the session delivery registry. The + * promise tracks durable marker acceptance, but the worker never waits + * on it. Its persisted pending obligation survives process restart. */ deliver: (steer: SteerMessage) => Promise; /** Settings-resolved engine per role (model slots wave, 2026-09-20): @@ -96,8 +97,8 @@ export interface JobRunnerDeps { export interface JobRunner { /** Run one job's sub-agent loop to completion (background; never throws). * The tool input rides along from dispatch (the spawn fires mid-stream, - * before the tool part is persisted — only crash-recovery re-spawns - * resolve the input from the transcript). */ + * before the tool part is persisted). Recovery reads saved job input, + * with transcript fallback for legacy jobs. Delivery is not awaited. */ runJob(jobId: string, input?: JobToolInput): Promise; } @@ -270,14 +271,15 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner { } } - /** Resolve the job's tool input: dispatch passes it directly; only a - * crash-recovery re-spawn (no input) falls back to scanning the - * transcript (by then onFinish has long persisted the part). */ + /** Dispatch input wins, then the job's saved arguments. Legacy jobs + * without saved input fall back to the durable transcript, which may + * lack a call if the dispatching turn died before settlement. */ function resolveInput( job: JobRecord, input: JobToolInput | undefined, ): JobToolInput | undefined { if (input) return input; + if (job.input !== undefined) return job.input as JobToolInput; const chat = chatStore.getChat(job.chatId); if (!chat) return undefined; // 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 - * the wake pipeline. The pipeline flips the status when the lesson has - * actually resumed — until then the job reads "running" so the client's - * activity card keeps polling and re-syncs once, at the end. + * Save the computation outcome and session delivery obligation together. + * Worker liveness ends after cleanup, not after a lesson consumes the result. */ async function finishJob(jobId: string, result: ResearchResult | DemoResult | VisualResult | PdfResult): Promise { const job = jobStore.getJob(jobId); 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 record and queues the abort steer itself. if (job.status !== "running") return; - jobStore.updateJob(jobId, { result, activity: "delivering result" }); - const steer = buildSubagentSteer(job, result); - try { - await deliver(steer); - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - console.error(`[job ${jobId}] steer delivery failed:`, err); - jobStore.updateJob(jobId, { status: "error", error: reason, activity: "delivery failed" }); - } + const failed = isSubagentFailure(job.kind, result); + jobStore.updateJob(jobId, { + result, + status: failed ? "error" : "done", + activity: failed ? "failed" : "finished", + delivery: { kind: "result", status: "pending" }, + }); + // 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. */ @@ -343,7 +346,12 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner { const { job } = ctx; 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 prompt = [ diff --git a/src/agent/subscription-bridge.ts b/src/agent/subscription-bridge.ts index b1a558e..97ef6f8 100644 --- a/src/agent/subscription-bridge.ts +++ b/src/agent/subscription-bridge.ts @@ -1,4 +1,5 @@ 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 type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts"; @@ -18,6 +19,7 @@ export function nativeSubscriptionId(logicalId: string): NativeSubscriptionProvi 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; } +function runtimeConnected(): boolean { return desktopConnected() || webSubscriptionsEnabled(); } function notify(state: State) { for (const listener of state.listeners) { try { listener(); } catch { /* observer isolation */ } } } function invalidate(state: State, providerId?: NativeSubscriptionProviderId) { 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); 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", () => { + if (webSubscriptionsEnabled()) return; invalidate(state); for (const pending of state.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error("The desktop connection closed.")); } state.pending.clear(); notify(state); @@ -70,6 +76,7 @@ function bridge(): State { } export function subscriptionRequest(op: "snapshot" | "lease", fields: Record = {}): Promise { const state = bridge(); + if (webSubscriptionsEnabled()) return webSubscriptionRequest("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.")); return new Promise((resolve, reject) => { const id = `${process.pid}:${++state.counter}`; @@ -82,17 +89,17 @@ export function subscriptionRequest(op: "snapshot" | "lease", fields: Record< } export async function refreshSubscriptionModels(): Promise { const state = bridge(); - if (!desktopConnected()) { invalidate(state); return; } + if (!runtimeConnected()) { invalidate(state); return; } try { accept(state, await subscriptionRequest("snapshot")); } catch { invalidate(state); notify(state); } } export function nativeSubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus | undefined { return bridge().statuses.get(providerId); } export function nativeSubscriptionConnected(providerId: NativeSubscriptionProviderId): boolean { 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 nativeSubscriptionModels(): Model[] { return desktopConnected() ? [...bridge().models.values()].flat() : []; } +export function nativeSubscriptionModels(): Model[] { return runtimeConnected() ? [...bridge().models.values()].flat() : []; } export function nativeSubscriptionModel(logicalId: string, modelId: string): Model | 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 subscriptionScope(providerId: NativeSubscriptionProviderId, selectedModelId: string): NativeSubscriptionScope { diff --git a/src/agent/subscription-runtime.ts b/src/agent/subscription-runtime.ts new file mode 100644 index 0000000..56e1f07 --- /dev/null +++ b/src/agent/subscription-runtime.ts @@ -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; +interface Runtime { + request?: (channel: SubscriptionRuntimeChannel, op: string, fields: Record) => Promise; + listeners: Map 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): 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(channel: SubscriptionRuntimeChannel, op: string, fields: Record): Promise { + if (!webSubscriptionsEnabled()) return Promise.reject(new Error("The web subscription owner is unavailable.")); + return runtime().request!(channel, op, fields); +} diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts index 08caa42..6fe6ac1 100644 --- a/src/agent/teacher-agent.ts +++ b/src/agent/teacher-agent.ts @@ -15,10 +15,11 @@ import { type TeacherToolContext, } from "./tools.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 { 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 { 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 * 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 - * 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 * turn). */ -function makeQuestionStopper() { +function makeQuestionStopper(kind: AgentKind) { return (context: { message: { content: Array<{ type: string; id?: string; name?: string }> }; toolResults: Array<{ toolCallId: string; isError?: boolean }>; }): boolean => { const called = new Set( 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!), ); 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) { 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 // ("practice" is not a ModelRole) honest about that. const engine = opts.engine ?? piEngineFor(kind as "prober" | "teacher"); - const stopper = makeQuestionStopper(); + const stopper = makeQuestionStopper(kind); const systemPrompt = opts.promptSwitches ? composeInstructions(kind, opts.promptSwitches) : composedInstructions[kind]!; @@ -190,7 +196,16 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent systemPrompt, model: engine.model, 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 ?? [], }, streamFn: engine.streamFn, @@ -312,10 +327,9 @@ export function bareQuestionTools(): Record> { * from turn 1 of a kind:"practice" session — no prober→teacher split, * no plan tools, no question tools. Its ENTIRE registry is * problem + conclude (tools/practice.ts); the doctrine prompt - * (prompts/practice/) carries the method. The question-stop rule does - * not apply (problem rides terminate: true natively — - * QUESTION_TOOLS does not include it), so baseAgentOptions's stopper is - * a no-op here; it stays wired for uniformity. + * (prompts/practice/) carries the method. A successful problem pose ends + * the run, natively for a problem-only batch and via the shared stop hook + * for a mixed batch (for example, send_to_user + problem). */ export function createPracticeAgent(ctx: PracticeToolContext, opts: AgentBuildOptions = {}) { const tools = opts.tools ?? practiceTools(ctx); diff --git a/src/agent/tools/jobs.ts b/src/agent/tools/jobs.ts index 00a78ca..e3ed6a5 100644 --- a/src/agent/tools/jobs.ts +++ b/src/agent/tools/jobs.ts @@ -78,7 +78,7 @@ function dispatch( input: ResearchInput | DemoInput | VisualInput | PdfInput, ): string { 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); return `Dispatched (job ${job.id}). ${ack}`; } diff --git a/src/agent/tools/probe.ts b/src/agent/tools/probe.ts index 11e3cd4..7cd9b84 100644 --- a/src/agent/tools/probe.ts +++ b/src/agent/tools/probe.ts @@ -42,8 +42,9 @@ function probeConcluded(messages: readonly LhMessage[]): boolean { * live), so the agent sees the fixed PROBE_DONE_ACK_TEXT — never an * echo. details = {ok:true} only. * - * The first probe_done in the transcript is also the construction seam: - * from that message on, every request builds the TEACHER agent. + * The first concluded probe_done (non-failed call plus matching successful + * result) is also the construction seam: from that result on, every request + * builds the TEACHER agent. Bare calls and refused results keep probing. */ export function probeDoneTool(ctx: ProbeDoneContext): Record> { return { diff --git a/src/agent/tools/send-to-user.ts b/src/agent/tools/send-to-user.ts new file mode 100644 index 0000000..fb0e409 --- /dev/null +++ b/src/agent/tools/send-to-user.ts @@ -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 = { + 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."), +}; diff --git a/src/core/answer-presentation.ts b/src/core/answer-presentation.ts new file mode 100644 index 0000000..ffc61ef --- /dev/null +++ b/src/core/answer-presentation.ts @@ -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(); // saved position -> original position + const used = new Set(); + 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; +} diff --git a/src/core/compaction.ts b/src/core/compaction.ts index 8e3f768..4ca0fab 100644 --- a/src/core/compaction.ts +++ b/src/core/compaction.ts @@ -272,9 +272,9 @@ export function newCompactionMarker(summary: string, details: CompactionDetails) }; } -/** The ACTIVE compaction marker: the newest one in the record (chained - * compactions — the last one rules; older markers fall inside the - * dropped span and never render). */ +/** The ACTIVE compaction marker: the newest one with a retained-message + * id. Its summary supersedes older markers even when their stored + * positions remain inside its retained tail. */ export function activeCompactionOf(messages: readonly LhMessage[]): (LhMarkerMessage & { details: CompactionDetails }) | null { for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]!; diff --git a/src/core/content-reader.ts b/src/core/content-reader.ts new file mode 100644 index 0000000..a774094 --- /dev/null +++ b/src/core/content-reader.ts @@ -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(); +/** 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; +} diff --git a/src/core/contracts/jobs.ts b/src/core/contracts/jobs.ts index 1b14519..144e974 100644 --- a/src/core/contracts/jobs.ts +++ b/src/core/contracts/jobs.ts @@ -21,6 +21,12 @@ export interface JobRecord { chatId: string; /** The tool part this job fulfills. */ 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; /** Latest human-readable activity line, e.g. `web_search: "sigma.js v3"`. */ activity: string; @@ -148,42 +154,32 @@ export function accumulateJobUsage( * sourced briefs) — tracking a live activity line in the job record. * ONE central client poll of GET /api/jobs?chatId=… (in Chat) drives every * 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. * - * 3. On completion the runner builds a STEER MESSAGE (buildSubagentSteer) - * and hands it to the wake pipeline (src/server/jobs.ts): a user-role - * result-carrying native marker — the content is what the MODEL needs - * (the research brief, the builder's notes), plus a data-subagent-artifact - * part with what the UI renders (the full result: brief + sources / - * SVG / demo HTML). Provider conversion drops data parts, so artifact - * payloads never enter the teacher's context. metadata.subagentResult - * identifies the job; the UI renders these as sub-agent result cards, - * NEVER as learner interjections. + * 3. Dispatch saves input on the job before spawn. On completion the + * runner saves result + terminal worker status + pending delivery in + * one job update, then queues buildSubagentSteer WITHOUT waiting for + * delivery. The native custom marker's content is model-facing text; + * details carry the full artifact payload for the UI. Worker resources + * are released even if a question keeps the result pending indefinitely. + * Only computing workers appear in Background agents or accept abort. * - * 4. The wake pipeline delivers the steer by firing a SERVER-SIDE teacher - * turn through the same turn composition as a client request - * (handleChatRequest) — write-ahead persist, turn liveness, extraction - * trigger: one code path. A per-chat serialized queue: a steer waits - * while a turn is in flight OR a question tool part is pending (an - * unanswered client-side tool call breaks provider conversion — the - * steer fires right after the learner's continuation), then fires; a 409 - * (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. + * 4. Session delivery owns one registry entry per deterministic steer ID, + * including while a wake is in flight or a 409/403 returns its cargo. + * A live agent takes it via pi steering; registration restores saved + * pending jobs before the answer run's first poll. Otherwise the idle + * wake uses the normal turn composition and respects pending questions, + * dismissal, and background consent. A saved transcript marker completes + * delivery; HTTP 200 or streamed visibility alone does not. * - * CONTINUATION COALESCING: the idle window above can starve — a - * quiz-chaining teacher ends every turn with a new pending check, so - * the gates never stay open long enough for a wake to fire. The fix: - * when a client turn wins the turn registry, the handler CLAIMS every - * queued, undelivered steer (jobs.ts claimSteers) and appends it after - * the learner's answer in that very turn — conversion-safe, because the - * answered quiz carries its output. Every answer flushes pending - * 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. + * Failed starts remain quietly pending until a learner run or restart + * recovery, without new timers, visible delivery errors, or worker-status + * changes. Existing 409 backoff and consent deferral remain. Accepted + * turns are never automatically replayed. Legacy running records with a + * result become finished/pending; legacy terminal records with no + * delivery metadata remain untouched. Session deletion discards pending + * deliveries rather than treating them as running work. * * 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 diff --git a/src/core/coverage.ts b/src/core/coverage.ts index fe5b523..50c3ce0 100644 --- a/src/core/coverage.ts +++ b/src/core/coverage.ts @@ -1,9 +1,9 @@ /** * 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 - * text blocks — no learner messages, no thinking, no tool calls or - * results, no quiz blocks. Everything else is INVISIBLE to coverage: it - * neither opens nor joins a run, so answering a quiz or editing a learner + * prose — ordinary text blocks and explicit send_to_user messages. No + * learner messages, thinking, other tool calls/results, or quiz blocks. + * 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 * the extractor saw": the fingerprint (sha256 of a message's view lines) * and the extractor's prompt share ONE selection — the teacher prose. @@ -22,33 +22,27 @@ import { createHash } from "node:crypto"; 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 }; -/** 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 * between the extractor's prompt and messageFingerprint (coverage). * TEACHER PROSE ONLY (owner, 2026-09-28): an assistant message yields one - * line per text block; everything else (user messages, tool results, - * markers) yields NOTHING and is invisible to coverage. `mid` is the - * message id — part of the hash input, so the same prose in two messages + * line per learner-facing prose block; everything else (user messages, + * tool results, markers) yields NOTHING and is invisible to coverage. + * `mid` is the message id — part of the hash input, so the same prose in two messages * is two slots (per-message coverage). */ -export function messageViewLines(message: LhMessage, mid: string): string[] { +export function messageViewLines( + message: LhMessage, + mid: string, + failedOutputCalls: ReadonlySet = failedOutputCallIds([message]), +): string[] { const lines: string[] = []; 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()}`); } return lines; @@ -62,9 +56,10 @@ export function messageViewLines(message: LhMessage, mid: string): string[] { */ export function teacherProseBlocks(messages: ReadonlyArray): string[] { const out: string[] = []; + const failedOutputCalls = failedOutputCallIds(messages); for (const message of messages) { 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; } @@ -79,9 +74,9 @@ export function viewTokens(lines: string[]): number { * 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. */ -export function messageFingerprint(message: LhMessage): string { +export function messageFingerprint(message: LhMessage, failedOutputCalls?: ReadonlySet): string { 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"); } @@ -95,7 +90,12 @@ export interface CoverageSlot { /** Fingerprint a whole transcript, in order (used by the trigger, the run, * and the coverage view — one derivation everywhere). */ export function coverageSlots(messages: ReadonlyArray): 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. */ @@ -131,16 +131,14 @@ export function coverageOf( let uncoveredTokens = 0; for (let i = 0; i < slots.length; 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; else { uncovered.push(i); uncoveredTokens += slot.tokens; } } - const visibleCount = slots.length - slots.filter( - (s) => messageViewLines(s.message, s.message.id).length === 0, - ).length; + const visibleCount = slots.filter((s) => s.tokens > 0).length; return { slots, uncovered, diff --git a/src/core/dictation.ts b/src/core/dictation.ts new file mode 100644 index 0000000..45d6826 --- /dev/null +++ b/src/core/dictation.ts @@ -0,0 +1,90 @@ +/** Nova-3 monolingual language codes, including documented locale aliases. + * Source: https://developers.deepgram.com/docs/models-languages-overview + * Multilingual is a separate mode; its supported language set differs. + */ +const NOVA_3_LANGUAGES = [ + ["Afrikaans", ["af", "af-ZA"]], + ["Arabic", ["ar", "ar-AE", "ar-SA", "ar-QA", "ar-KW", "ar-SY", "ar-LB", "ar-PS", "ar-JO", "ar-EG", "ar-SD", "ar-TD", "ar-MA", "ar-DZ", "ar-TN", "ar-IQ", "ar-IR"]], + ["Armenian", ["hy"]], + ["Assamese", ["as", "as-IN"]], + ["Belarusian", ["be"]], + ["Bengali", ["bn"]], + ["Bosnian", ["bs"]], + ["Bulgarian", ["bg"]], + ["Catalan", ["ca"]], + ["Chinese (Cantonese, Traditional)", ["zh-HK"]], + ["Chinese (Mandarin, Simplified)", ["zh", "zh-CN", "zh-Hans"]], + ["Chinese (Mandarin, Traditional)", ["zh-TW", "zh-Hant"]], + ["Croatian", ["hr"]], + ["Czech", ["cs", "cs-CZ"]], + ["Danish", ["da", "da-DK"]], + ["Dutch", ["nl"]], + ["English", ["en", "en-US", "en-AU", "en-GB", "en-IN", "en-NZ"]], + ["Estonian", ["et"]], + ["Finnish", ["fi"]], + ["Flemish", ["nl-BE"]], + ["French", ["fr", "fr-CA"]], + ["Georgian", ["ka", "ka-GE"]], + ["German", ["de"]], + ["German (Switzerland)", ["de-CH"]], + ["Greek", ["el"]], + ["Gujarati", ["gu", "gu-IN"]], + ["Hebrew", ["he"]], + ["Hindi", ["hi"]], + ["Hungarian", ["hu"]], + ["Indonesian", ["id"]], + ["Italian", ["it"]], + ["Japanese", ["ja"]], + ["Kannada", ["kn"]], + ["Kazakh", ["kk", "kk-KZ"]], + ["Korean", ["ko", "ko-KR"]], + ["Latvian", ["lv"]], + ["Lithuanian", ["lt"]], + ["Macedonian", ["mk"]], + ["Malay", ["ms"]], + ["Marathi", ["mr"]], + ["Mongolian", ["mn"]], + ["Nepali", ["ne"]], + ["Norwegian", ["no"]], + ["Pashto", ["ps", "ps-AF"]], + ["Persian", ["fa"]], + ["Polish", ["pl"]], + ["Portuguese", ["pt", "pt-BR", "pt-PT"]], + ["Punjabi", ["pa", "pa-IN"]], + ["Romanian", ["ro"]], + ["Russian", ["ru"]], + ["Serbian", ["sr"]], + ["Slovak", ["sk"]], + ["Slovenian", ["sl"]], + ["Spanish", ["es", "es-419"]], + ["Swedish", ["sv", "sv-SE"]], + ["Tagalog", ["tl"]], + ["Tamil", ["ta"]], + ["Telugu", ["te"]], + ["Thai", ["th", "th-TH"]], + ["Turkish", ["tr", "tr-TR"]], + ["Ukrainian", ["uk"]], + ["Urdu", ["ur"]], + ["Vietnamese", ["vi"]], +] as const; + +export type DictationLanguage = "multi" | (typeof NOVA_3_LANGUAGES)[number][1][number]; + +export const DICTATION_LANGUAGES: readonly { code: DictationLanguage; label: string }[] = [ + { code: "multi", label: "Multilingual" }, + ...NOVA_3_LANGUAGES.flatMap(([name, codes]) => + codes.map((code, i) => ({ code, label: i === 0 ? name : `${name} (${code})` })), + ).sort((a, b) => a.label.localeCompare(b.label, "en")), +]; + +export function isDictationLanguage(value: unknown): value is DictationLanguage { + return DICTATION_LANGUAGES.some(({ code }) => code === value); +} + +/** Only language varies. Keep the owner's tuned streaming parameters. */ +export function deepgramListenUrl(language: DictationLanguage): string { + return "wss://api.deepgram.com/v1/listen" + + "?model=nova-3&encoding=linear16&sample_rate=16000&channels=1" + + "&smart_format=true&punctuate=true&endpointing=300" + + `&language=${encodeURIComponent(language)}`; +} diff --git a/src/core/events.ts b/src/core/events.ts index e639006..dba4236 100644 --- a/src/core/events.ts +++ b/src/core/events.ts @@ -38,6 +38,8 @@ export interface ServerEvents { "activity-changed": Record; /** The session list changed (create, delete, title). */ "chats-changed": Record; + /** Web subscription state changed. No account or credential data on the bus. */ + "subscriptions-changed": Record; /** A chat's record changed WITHOUT a turn (the silent dismissal write). * Same notify-then-fetch contract as the turn events: open views of * the chat refetch and adopt the durable record. */ @@ -61,6 +63,7 @@ export const SERVER_EVENT_TYPES = [ "jobs-changed", "activity-changed", "chats-changed", + "subscriptions-changed", "record-changed", "compaction-changed", ] as const satisfies readonly ServerEventType[]; diff --git a/src/core/index.ts b/src/core/index.ts index b4106d7..bae8b33 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -47,6 +47,7 @@ export * from "./input-events.ts"; // the deleted server projection to the client side. Pure, browser-safe. export * from "./view.ts"; export * from "./tool-parts.ts"; +export * from "./answer-presentation.ts"; export * from "./prices.ts"; export * from "./context-usage.ts"; export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts"; @@ -58,6 +59,7 @@ export * from "./fuzzy.ts"; // the user-settings record's shape/sanitizer (pure) and its fully-resolved // view. Browser-safe — the Wave-4 panel imports these directly. export * from "./settings.ts"; +export * from "./dictation.ts"; // graph-deletion.ts (wave 6): the whole module is PURE but storage-port // driven — the deletion RUNS server-side only. The shell needs exactly one // two type-only things from it: the ChatDeleteMode union (the delete diff --git a/src/core/input-events.ts b/src/core/input-events.ts index 42d6985..714d7fb 100644 --- a/src/core/input-events.ts +++ b/src/core/input-events.ts @@ -16,6 +16,9 @@ * continue — run the next turn on the current record (mount * continuations, job-settle re-syncs: the record ends at an * answered question with no teacher reaction yet). + * retry — retry a failed model response (issue #110), without new + * input. Failed attempts stay in the record; only the retry's + * temporary agent history omits the trailing failures. * * (The plan verdict input died 2026-09-26 with the verdict itself — the * learner's post-plan reply is a natural text message.) @@ -45,6 +48,8 @@ export type ChatInput = | { type: "text"; text: string } | { type: "answer"; toolCallId: string; answer: QuestionAnswerData } | { type: "continue" } + /** The failed-turn retry (issue #110): the banner's Retry button. */ + | { type: "retry" } /** The practice dock's two buttons (owner, 2026-10-04): the learner's * thinking on the ACTIVE problem — one lane (owner 2026-10-05): * whether it claims an answer is read from the text. One @@ -170,6 +175,8 @@ export function parseChatInput(input: unknown): ChatInput { return { type: "answer", toolCallId: i.toolCallId, answer: i.answer as QuestionAnswerData }; case "continue": return { type: "continue" }; + case "retry": + return { type: "retry" }; case "thinking": if (typeof i.problemCallId !== "string" || i.problemCallId.length === 0) { throw new ChatInputError("thinking input requires problemCallId"); @@ -210,6 +217,10 @@ export function inputAppends( } case "continue": return dismissalConclusion(record); + case "retry": + // The input already landed write-ahead. retryDrops affects only + // the temporary agent history — nothing new appends here. + return []; case "thinking": { const active = activeProblemOf(record.messages); if (!active || active.toolCallId !== input.problemCallId) { @@ -233,6 +244,61 @@ export function inputAppends( } } +/** The failure owning the ephemeral banner. Plan presentation and + * compaction do not resolve an error; substantive later input/output does. + * This is deliberately broader than Retry eligibility below. */ +export function failedResponseAtEnd(messages: readonly LhMessage[]): Extract | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]!; + if (m.role === "custom" && (m.customType === "plan-block" || m.customType === "compaction")) continue; + return m.role === "assistant" && m.stopReason === "error" ? m : undefined; + } + return undefined; +} + +/** Does the record end in a failure with input that can be retried? + * Aborts are deliberate stops; compaction after a failure requires new + * learner input rather than a retry using a changed context. */ +export function endsInFailedTurn(messages: readonly LhMessage[]): boolean { + const { ids, before } = failedTail(messages); + return ids.size > 0 && before !== undefined && before.role !== "assistant"; +} + +/** The ids omitted ONLY from the retry's temporary agent history. + * Failed attempts (including partial text and their ids) stay on disk. + * Pi's provider conversion already skips error/aborted responses, but + * continue() rejects an assistant tail BEFORE conversion. Omitting the + * trailing failures lets that continuation start from the saved input or + * completed tool results without replaying tools or resubmitting input. + * Plan blocks may follow the failure and stay; later compaction blocks + * Retry without changing compaction itself. */ +export function retryDrops(messages: readonly LhMessage[]): Set { + const { ids, before } = failedTail(messages); + if (ids.size === 0) { + const failure = failedResponseAtEnd(messages); + if (failure) { + throw new ChatInputError(`${failure.errorMessage ?? "An error occurred."}\n\nThis response cannot be retried after compaction. Send a new message to continue.`); + } + throw new ChatInputError("retry input requires a failed turn to retry"); + } + if (!before || before.role === "assistant") throw new ChatInputError("retry input leaves no input to continue from"); + return ids; +} + +/** Walk back over failed assistants and UI-only plan blocks. A compaction + * is a boundary, not a marker Retry may cross. A failure AFTER a compaction + * can still retry: that compacted context was already its starting state. */ +function failedTail(messages: readonly LhMessage[]): { ids: Set; before: LhMessage | undefined } { + const ids = new Set(); + let i = messages.length - 1; + for (; i >= 0; i--) { + const m = messages[i]!; + if (m.role === "assistant" && m.stopReason === "error") ids.add(m.id); + else if (!(m.role === "custom" && m.customType === "plan-block")) break; + } + return { ids, before: messages[i] }; +} + /** The deferred conclude synthesis (owner ruling, 2026-10-04): a * problem-dismissal marker waits for the user's next input — the pair * (synthetic conclude call + ack) rides ahead of it, so the agent reads diff --git a/src/core/math-delims.ts b/src/core/math-delims.ts index b939054..21d7bed 100644 --- a/src/core/math-delims.ts +++ b/src/core/math-delims.ts @@ -1,572 +1,121 @@ -/** - * Legacy math delimiter preprocessing — the \(...\)/\[...\] reader. - * - * Current authoring uses `$...$` inline and `$$...$$` on separate lines for - * display (owner, 2026-10-12). This pure transform keeps correctly formed - * old backslash-delimited content readable; dollar math passes through to - * 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. - */ +/** Shared Markdown/TeX boundaries for legacy delimiter compatibility and + * display-only tool-content recovery. Source arguments are never rewritten. */ +import { + canParseTex, knownTexCommand, mapMathContent, numericPercentEscape, outsideTexComments, + readContentSpans, recoverDocumentLayout, texRanges, +} from "./content-reader.ts"; -const INLINE_OPEN = "\\("; -const INLINE_CLOSE = "\\)"; -const DISPLAY_OPEN = "\\["; -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 = { - "\b": "\\b", - "\t": "\\t", - "\n": "\\n", - "\v": "\\v", - "\f": "\\f", - "\r": "\\r", +const escaped = (text: string, i: number) => { + let n = 0; while (i > 0 && text[--i] === "\\") n++; + return n % 2 === 1; }; -const DEBRIS_RE = /[\b\t\n\v\f\r]/; - -/** Display spans keep real newlines (multi-line equations) — debris there is - * everything except \n (a lone real \r never occurs in authored text, and - * `\right`/`\rho`/`\rangle` corruption is common in display math). */ -const DISPLAY_DEBRIS_RE = /[\b\t\v\f\r]/; - -/** Shared display-only repair. Keep raw arguments and model history unchanged. */ -export function repairMathForDisplay(text: string): string { - return healOverescapedCommands(restoreMathEscapes(text)); +/** Preserve the legacy list-continuation policy: indented display math + * degrades to inline rather than disrupting list structure. Literal code + * indentation is already protected by the shared reader. */ +function indented(text: string, i: number): boolean { + const prefix = text.slice(text.lastIndexOf("\n", i - 1) + 1, i); + return prefix.length > 0 && /^[ \t]+$/.test(prefix); +} +function blockPosition(text: string, i: number): boolean { + return /^[ \t]*$/.test(text.slice(text.lastIndexOf("\n", i - 1) + 1, i)); } +/** 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 = { + "\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 { - if (!DEBRIS_RE.test(text)) return text; - return restoreDollarMathEscapes(restoreLegacyMathEscapes(text)); + if (!/[\b\t\n\v\f\r]/.test(text)) return 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. */ -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(""); -} +const commandSymbol = /[,;:!|{}%]/; -function dollarRun(text: string, i: number): number { - let count = 0; - while (text[i + count] === "$") count += 1; - return count; -} - -/** Native math fences start at a line's block position; one-line $$…$$ - * remains inline. A flow opener may have metadata, but never another $. */ -function dollarIsDisplay(text: string, i: number, size: number): boolean { - if (size < 2 || !displayIsBlockPositioned(text, i)) return false; - const end = text.indexOf("\n", i + size); - return !text.slice(i + size, end === -1 ? text.length : end).includes("$"); -} - -/** Match micromark's delimiter runs: exact size inline, at least the - * opener's size on an otherwise-empty line for display. Inside math, - * backslashes are TeX data, not Markdown escapes for closing dollars. */ -function findDollarClose( - 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 }; - } +/** Decode prefixes and row separators according to ORIGINAL TeX context. + * A native pair is literal; an encoded pair supplies a factor, so an encoded + * four-backslash row becomes TWO, never a command prefix. Unknown commands + * without that structural evidence are left alone. */ +function recoverCommandRuns(inner: string, display: boolean): string { + const ranges = texRanges(inner); + const recovered = inner.replace(/\\+/g, (run: string, offset: number) => { + const contexts = ranges.filter(r => offset >= r.start && offset < r.end); + if (contexts.some(r => r.kind === "row" || r.kind === "comment") || run.length % 2 !== 0) return run; + const next = inner[offset + run.length] ?? ""; + const encoded = contexts.filter(r => r.kind === "encoded").at(-1); + const word = /^[A-Za-z]+/.exec(inner.slice(offset + run.length))?.[0]; + if (encoded) { + const factor = encoded.factor!; + const rows = run.length % (2 * factor) === 0; + const command = word ? knownTexCommand(word) : commandSymbol.test(next); + if (run.length % factor === 0 && (rows || command)) return "\\".repeat(run.length / factor); + return run; } - return undefined; - } - for (let j = from; j < text.length;) { - if (text[j] !== "$") { - j += 1; - continue; - } - const count = dollarRun(text, j); - if (count === size) return { at: j, after: j + count }; - 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), - ); + if (next === "%") return numericPercentEscape(inner, offset + run.length) ? "\\" : run; + if (word) return word.length > 1 && knownTexCommand(word) ? "\\" : run; + return next && commandSymbol.test(next) ? "\\" : run; + }); + // Structural evidence is primary (parse success cannot prove row + // preservation). As an additional guard, never turn parseable source into + // a parse failure through partial command recovery. + if (recovered !== inner && !canParseTex(recovered, display) && canParseTex(inner, display)) return inner; + return recovered; } export function healOverescapedCommands(text: string): string { - if (!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; - } - 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(""); + return text.includes("\\\\") ? mapMathContent(text, recoverCommandRuns) : text; +} + +/** Presentation-only, language-independent recovery. Shape parsing, saved + * identities, grading and model history must use the unprojected source. */ +export function repairMathForDisplay(text: string): string { + return healOverescapedCommands(restoreMathEscapes(recoverDocumentLayout(text))); } diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index 2983e4f..c9d5a46 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -98,6 +98,55 @@ export function asAgentMessages(messages: readonly LhMessage[]): 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[] { + 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 { + const failed = new Set(); + 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 ────────────────────────────────────────────────────────────── /** The app-wide message id shape (same as the UIMessage-era mint). */ @@ -502,9 +551,10 @@ function resultIdsFrom(messages: readonly AgentMessage[]): Set { * - markers flatten to a user message carrying their content (the * canonical text single-sourced in core/contracts); plan blocks are * UI-only and filtered. - * - dangling tool calls (an aborted turn's unanswered questions) get the - * synthesized interrupted result — the provider protocol's hard - * invariant: a tool call must be followed by its result. + * - dangling calls from successfully generated assistant messages (for + * example, an unanswered question) get a synthesized interrupted result. + * Error/aborted generations do not: Pi drops those assistant messages, + * so manufacturing their results would leave orphan results on the wire. * * Contract (pi-agent-core): never throws — an unknown shape passes * through untouched rather than killing the loop. @@ -513,8 +563,9 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] { // THE COMPACTION CUT (owner round 14, 2026-10-05): the ACTIVE marker // (the newest — chained compactions rule last) names the first kept // message; everything before it is replaced by the marker's content - // (header + summary) rendered ONCE, up top. The marker itself is then - // skipped in place — it must never render twice. A cut naming a + // (header + summary) rendered ONCE, up top. All compaction markers + // 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). const lhMessages = messages as readonly LhMessage[]; const active = activeCompactionOf(lhMessages); @@ -555,11 +606,10 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] { // UI-only markers: plan blocks, and problem dismissals (the // synthesized conclude pair carries the model's face). if (marker.customType === "plan-block" || marker.customType === "problem-dismissed") continue; - // The ACTIVE compaction marker already rendered as the head — - // never twice. (Stale markers before the cut are unreachable; a - // marker whose cut could not resolve has no head and renders as - // its user text — honest degradation.) - if (marker.customType === "compaction" && head !== null && marker.id === active!.id) continue; + // The head supersedes every compaction marker, not only its own + // stored position. Without a resolved head, preserve the existing + // full-history fallback, including the summary text. + if (marker.customType === "compaction" && head !== null) continue; out.push({ role: "user", content: [{ type: "text", text: marker.content }], @@ -570,11 +620,12 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] { // Strip the record id: the wire carries the native shape only. const { id: _id, ...native } = m as LhMessage; out.push(native as Message); - // Dangling-call synthesis: an assistant message whose toolCalls have - // no results in the remaining tail (abort mid-question) — the - // provider rejects unanswered calls outright. + // Repair unanswered calls from completed generations, not calls in + // failed generations. Pi filters error/aborted assistants itself; + // adding results for their unexecuted calls would leave orphans. if (role === "assistant") { const assistant = m as AssistantMessage; + if (assistant.stopReason === "error" || assistant.stopReason === "aborted") continue; const calls = assistant.content.filter((b) => b.type === "toolCall"); if (calls.length === 0) continue; const answered = resultIdsFrom(messages.slice(i + 1)); @@ -618,15 +669,35 @@ export function danglingQuestionOf( return null; } -/** The probe→teach construction seam (chat-handler): a probe_done call in - * the transcript — any state — means the probe is concluded: teach. */ -export function hasProbeDone(messages: readonly LhMessage[]): boolean { +/** Successful probe_done result MESSAGE ids, in transcript order. A call + * from a failed/aborted generation grants no completion authority. Its + * later result must match both call id and tool name, must not be an + * error, and must not explicitly refuse with details.ok:false. Older + * clean acknowledgments without ok:true still count. No history changes. */ +export function probeDoneResultIds(messages: readonly LhMessage[]): Set { + const calls = new Set(); + const results = new Set(); for (const m of messages) { - if ((m as { role?: string }).role !== "assistant") continue; - const assistant = m as AssistantMessage; - if (assistant.content.some((b) => b.type === "toolCall" && b.name === "probe_done")) return true; + if (m.role === "assistant") { + if (m.stopReason === "error" || m.stopReason === "aborted") continue; + for (const block of m.content) { + if (block.type === "toolCall" && block.name === "probe_done") calls.add(block.id); + } + } else if ( + m.role === "toolResult" && m.toolName === "probe_done" && + calls.has(m.toolCallId) && !m.isError && + (m.details as { ok?: unknown } | null | undefined)?.ok !== false + ) { + results.add(m.id); + } } - return false; + return results; +} + +/** The probe→teach construction seam (chat-handler and the tool guard): + * only a concluded call plus a matching successful result means teach. */ +export function hasProbeDone(messages: readonly LhMessage[]): boolean { + return probeDoneResultIds(messages).size > 0; } /** Every toolResult id in the record — answer-event validation (the call diff --git a/src/core/phases.ts b/src/core/phases.ts index 4a6e0f4..0ecd870 100644 --- a/src/core/phases.ts +++ b/src/core/phases.ts @@ -33,7 +33,7 @@ * Both are strictly sequential; the two derivations agree on every settled * record (a call's part projects into the same view boundary). * - * Pure: no imports, no runtime deps — safe on server, client, and tests. + * Pure: only browser-safe core helpers — safe on server, client, and tests. */ /** The three phases of a session, as the picker / header / dividers see them. */ @@ -80,7 +80,10 @@ function advance( // plan → teach only via the first nodeId-tagged quiz CALL (the call, // not its answer — an errored-and-retried call still proves teaching // began, and the retry lands in the same group anyway). - if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") { + if ( + phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available" && + (part.output as { ok?: unknown } | null | undefined)?.ok !== false + ) { phase = "plan"; } // plan → teach via the first nodeId-tagged quizzing CALL — quiz OR @@ -153,7 +156,7 @@ export function phaseTimeline(messages: readonly PhaseMessage[]): Phase3[] { // ─── the native fold (Wave D: record-side phase over LhMessage[]) ─────────── -import type { LhMessage } from "./native-messages.ts"; +import { probeDoneResultIds, type LhMessage } from "./native-messages.ts"; /** The phase at `upToIndex` (inclusive; default: the last message) of a * NATIVE record. probe→plan advances at the probe_done call's clean @@ -163,18 +166,12 @@ import type { LhMessage } from "./native-messages.ts"; export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number): Phase3 { let phase: Phase3 = "probe"; const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1); + const concluded = probeDoneResultIds(messages.slice(0, last + 1)); for (let i = 0; i <= last; i++) { const m = messages[i]!; const role = (m as { role?: string }).role; if (role === "toolResult") { - const result = m as { toolName?: string; isError?: boolean }; - if ( - phase === "probe" && - result.toolName === "probe_done" && - !result.isError - ) { - phase = "plan"; - } + if (phase === "probe" && concluded.has(m.id)) phase = "plan"; } else if (role === "assistant") { const content = (m as { content?: ReadonlyArray<{ type?: string; name?: string; arguments?: unknown }> }).content ?? []; if (phase === "plan") { diff --git a/src/core/ports.ts b/src/core/ports.ts index ea67d93..b05d946 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -136,6 +136,8 @@ export interface CreateJobInit { toolCallId: string; /** First activity line (defaults to "starting"). */ activity?: string; + /** Saved on the job before its worker is spawned. */ + input?: unknown; } /** Mergeable fields — identity (id, kind, chatId, toolCallId, createdAt) is immutable. */ diff --git a/src/core/question-core.ts b/src/core/question-core.ts index f0b3214..8ba6c87 100644 --- a/src/core/question-core.ts +++ b/src/core/question-core.ts @@ -511,6 +511,21 @@ export interface DockSelection { 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 * displaces "Other". Multi-select: the option toggles alongside its siblings diff --git a/src/core/session-markdown.ts b/src/core/session-markdown.ts index 64a37b6..107e276 100644 --- a/src/core/session-markdown.ts +++ b/src/core/session-markdown.ts @@ -22,22 +22,15 @@ import type { LhMessage } from "./native-messages.ts"; import { + failedOutputCallIds, PRACTICE_CONCLUDE_TOOL, PRACTICE_PROBLEM_TOOL, problemInputForConclude, + SEND_TO_USER_TOOL, + userFacingTextOf, } from "./native-messages.ts"; 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): * practice tool RESULTS are acks — the statement, outcome, and verdict * all live in the assistant's call arguments, and the model solution @@ -48,6 +41,7 @@ export interface MarkdownContext { messages: ReadonlyArray; /** toolCallId → the call part's name + arguments. */ calls: Map | undefined }>; + failedOutputCalls?: ReadonlySet; } function markdownContext(messages: ReadonlyArray): MarkdownContext { @@ -66,7 +60,7 @@ function markdownContext(messages: ReadonlyArray): MarkdownContext { } } } - return { messages, calls }; + return { messages, calls, failedOutputCalls: failedOutputCallIds(messages) }; } /** solvedWithHints → "Solved with hints" — the outcome enum reads as prose. */ @@ -172,16 +166,16 @@ export function messageMarkdown( const parts: string[] = []; 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()}`); } } 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()}`); } } else if (role === "toolResult") { 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); parts.push(`### ${heading}${anchor}\n\n${body.join("\n\n")}`); } diff --git a/src/core/settings-resolution.ts b/src/core/settings-resolution.ts index ce4b6aa..1a1cbf9 100644 --- a/src/core/settings-resolution.ts +++ b/src/core/settings-resolution.ts @@ -35,6 +35,7 @@ import { type BackgroundModelRole, type FactorySettings, type GeneralSettings, + type DictationSettings, type CompactionSettings, type GraphSettings, type ModelSlot, @@ -69,6 +70,7 @@ export type EffectiveAppearance = FactorySettings["appearance"]; /** The concrete view: every knob resolved, no absent fields. */ export interface EffectiveSettings { general: Required; + dictation: Required; agent: { models: Record; /** Delta map as stored — consumers resolve absent key → enabled. */ @@ -176,6 +178,9 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings general: { autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate, }, + dictation: { + language: record?.dictation?.language ?? FACTORY_SETTINGS.dictation.language, + }, agent: { models, tools: agent.tools ?? {}, diff --git a/src/core/settings.ts b/src/core/settings.ts index 4daece8..f7966f1 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -32,6 +32,7 @@ import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts"; import type { ModelRole } from "../../config.ts"; import type { ChatSettings } from "./contracts/chat.ts"; import type { SearchEngineId } from "./search.ts"; +import { isDictationLanguage, type DictationLanguage } from "./dictation.ts"; /** The schema version this code understands. Bump ONLY for breaking shape * changes (a field renamed/meaning changed); pure additions never bump. */ @@ -231,10 +232,15 @@ export const ROLE_SLOT: Readonly> = { /** General settings — app-level behavior. Version DISPLAY and plans login * live outside the record (package.json / auth.json) by owner decision. */ 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; } +/** One app-wide dictation preference, shared by every composer. */ +export interface DictationSettings { + language?: DictationLanguage; +} + /** Agent settings — everything about the non-conversation machinery. */ export interface AgentSettings { /** Background-role model/thinking overrides (absent role = config pin). */ @@ -361,6 +367,7 @@ export interface CompactionSettings { export interface UserSettings { version: number; general?: GeneralSettings; + dictation?: DictationSettings; agent?: AgentSettings; /** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */ models?: ModelsSettings; @@ -378,6 +385,7 @@ export interface UserSettings { export interface FactorySettings { version: number; general: Required; + dictation: Required; appearance: { mode: "dark" | "light"; background: string | null; @@ -397,6 +405,7 @@ export interface FactorySettings { export const FACTORY_SETTINGS: FactorySettings = { version: CURRENT_SETTINGS_VERSION, general: { autoUpdate: true }, + dictation: { language: "en" }, appearance: { mode: "dark", background: null, // the dark default (#141414) lives in the mode's derivation curve @@ -615,6 +624,13 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null { if (r.general !== null && typeof r.general === "object" && !Array.isArray(r.general)) { out.general = sanitizeGeneral(r.general as Record); } + if (r.dictation !== undefined) { + const raw = r.dictation; + out.dictation = raw !== null && typeof raw === "object" && !Array.isArray(raw) && + isDictationLanguage((raw as Record).language) + ? { language: (raw as DictationSettings).language } + : {}; + } if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) { out.agent = sanitizeAgent(r.agent as Record); } diff --git a/src/core/tool-parts.ts b/src/core/tool-parts.ts index cb5585d..db4305c 100644 --- a/src/core/tool-parts.ts +++ b/src/core/tool-parts.ts @@ -30,7 +30,6 @@ import { Value } from "typebox/value"; import type { TSchema } from "typebox"; 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 { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts"; @@ -67,28 +66,9 @@ function boundaryParse(schema: TSchema, input: unknown): T | undefined { * never have been posed (e.g. `correctAnswer: undefined`, which Convert * widens to "") renders as a quiet fallback, not a broken quiz. */ -/** LaTeX-escape restoration, deep over every string field (the JSON-debris - * fix, owner ruling 2026-09-26): records persist RAW call args, so - * single-backslash LaTeX eaten by JSON escape resolution (FF ate the 'f' of - * \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 = {}; - for (const [k, v] of Object.entries(value)) out[k] = restoreMathEscapesDeep(v); - return out; - } - return value; -} - +/** Shape parsing never repairs authored text. The dock builds its answer + * data from this source copy; recovery belongs exclusively to presentation + * (ToolContentResponse), never option identities, grading, or snapshots. */ export function parseQuizInput(input: unknown): QuizToolInput | undefined { // quizDisplayInputSchema (question-core, owner 2026-10-03): base + 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 // tool-parts law). healEncodedArguments is the same pure pass the // boundary's withArgumentHeal runs. - const healedInput = healEncodedArguments(restoreMathEscapesDeep(input), quizDisplayInputSchema); + const healedInput = healEncodedArguments(input, quizDisplayInputSchema); const cleaned = boundaryParse(quizDisplayInputSchema, healedInput); if (!cleaned) 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). */ export function parseAskInput(input: unknown): AskToolInput | undefined { - const cleaned = boundaryParse(askInputSchema, restoreMathEscapesDeep(input)); + const cleaned = boundaryParse(askInputSchema, input); if (!cleaned) return undefined; if (askOptionProblems(cleaned.options ?? []).length > 0) return undefined; 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 * key Value.Clean strips, same as the quiz seam. */ export function parseExplainInput(input: unknown): ExplainToolInput | undefined { - const cleaned = boundaryParse(explainDisplayInputSchema, restoreMathEscapesDeep(input)); + const cleaned = boundaryParse(explainDisplayInputSchema, input); if (!cleaned) return undefined; if (explainInputProblems(cleaned).length > 0) return undefined; return cleaned; diff --git a/src/core/view.ts b/src/core/view.ts index 609de06..166d8d5 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -34,7 +34,7 @@ import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai"; 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 { QUESTION_TOOLS } from "./pending.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. */ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState): 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(); + 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 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" }); } else if (block.type === "toolCall") { 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 // hero of a conclude card is the modelSolution committed at POSE // time — a record fold, enriched here so the card renders pure. diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index af92de7..b0eef2a 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -17,7 +17,7 @@ import { createExtractionPipeline } from "../agent/subagents/extraction.ts"; import type { ModelRole } from "../../config.ts"; import { deliverJobResult, - noteSteerVisible, + noteSteersDurable, reconcileUserSteers, registerLiveAgent, restoreUncommittedAutomatic, @@ -42,8 +42,8 @@ import { import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope, type ProviderKeyMissing } from "../core/settings.ts"; import { beginTurn, endTurn } from "./turn-liveness.ts"; import { applyParkedDismissal } from "./dismissal-park.ts"; -import { registerTurn, unregisterTurn } from "./turn-runs.ts"; -import { persistTurn, triggerExtraction } from "./turn-service.ts"; +import { registerTurn, rememberTurnFailure, unregisterTurn } from "./turn-runs.ts"; +import { persistTurn, triggerExtraction, TurnPersistenceError } from "./turn-service.ts"; import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts"; import { composeInstructions } from "../agent/teacher-agent.ts"; import { ensureCompacted, kickCompaction, effectiveThresholdTokens } from "./compaction.ts"; @@ -55,10 +55,11 @@ import { ChatInputError, inputAppends, parseChatInput, + retryDrops, type TurnInput, } from "../core/input-events.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 @@ -79,6 +80,10 @@ import type {CallUsage, SteerMessage} from "../core/contracts.ts"; * 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({ chatStore: stores.chatStore, jobStore: stores.jobStore, @@ -182,6 +187,17 @@ export async function handleChatInput( return Response.json({ error: "Unknown chat" }, { status: 404 }); } + // Refuse an ineligible Retry before catalog refresh, compaction, or any + // turn/write effects. Revalidate after the awaited gates below too. + if (input.type === "retry") { + try { + retryDrops(record.messages); + } catch (err) { + if (err instanceof ChatInputError) return Response.json({ error: err.message }, { status: 400 }); + throw err; + } + } + // THE UNIVERSAL TURN GATE (BYOK, owner, 2026-09-22): a turn needs the // CONVERSATION chain — the chat's own model override over the strong // slot — and there is no factory floor; when both are empty the turn @@ -255,7 +271,20 @@ export async function handleChatInput( chatId, ); const compactedRecord = stores.chatStore.getChat(chatId); - if (compactedRecord) record = compactedRecord; + if (!compactedRecord) return Response.json({ error: "Unknown chat" }, { status: 404 }); + record = compactedRecord; + // Recompute Retry eligibility on current truth, still before beginTurn. + // A compaction marker or another writer may have landed during awaits. + // These omissions affect ONLY the agent context, never the saved record. + let retryOmissions = new Set(); + if (input.type === "retry") { + try { + retryOmissions = retryDrops(record.messages); + } catch (err) { + if (err instanceof ChatInputError) return Response.json({ error: err.message }, { status: 400 }); + throw err; + } + } // The input's native appends (pure; the honest 400 for an answer with no // pending question call). Computed BEFORE beginTurn so a refused input @@ -289,7 +318,11 @@ export async function handleChatInput( // the composer gates), so adjacency holds by construction. if (input.type === "answer" && danglingQuestionOf([...record.messages, ...appends]) !== null) { 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 }); return settledTurnResponse(); // the cheap terminal — no beginTurn, no turn } @@ -309,18 +342,26 @@ export async function handleChatInput( // ride this same write-ahead - the pump de-queued them at fire, so this // is the ONLY copy ever appended.) const merged = [...record.messages, ...appends]; + // Retry preserves every persisted attempt. Pi needs a non-assistant + // tail to start continue(), so its temporary history omits the trailing + // failures only. Count that SAME temporary prefix at settlement, or the + // retained failures would make persistTurn skip genuinely new output. + const agentMessages = input.type === "retry" ? merged.filter((m) => !retryOmissions.has(m.id)) : merged; const stagedAutomatic = isSubscriptionProvider(conversation.provider) && input.type === "steers"; - let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length; + let persistBaseCount = stagedAutomatic ? record.messages.length : agentMessages.length; try { 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) { 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 @@ -346,7 +387,7 @@ export async function handleChatInput( // the session is still probing: build the PROBER (quiz/ask + probe_done). // Both share this whole composition (write-ahead persist, liveness, // steers, usage, extraction) — only the agent differs. - const teaching = hasProbeDone(merged); + const teaching = hasProbeDone(agentMessages); // BYOK (owner, 2026-09-22): the conversation chain was resolved by the // turn gate above (chat.model override → strong slot → refused) — the // engine build is pure from here; thinking levels clamp natively. @@ -365,19 +406,23 @@ export async function handleChatInput( // sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the // user: quiz/ask/fs/plan/probe_done are always-on). if (isSubscriptionProvider(conversation.provider)) requestCycle = new SubscriptionRequestCycle({ - initialMessages: asAgentMessages(merged), + initialMessages: asAgentMessages(agentMessages), staged: stagedAutomatic ? asAgentMessages(appends) : undefined, foregroundOwed: !callPurpose.background, purpose: callPurpose, 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), }); const agentOpts: AgentBuildOptions = { engine, callPurpose, requestCycle, - messages: asAgentMessages(merged), + messages: asAgentMessages(agentMessages), onUsage: collectUsage, toolToggles: effective.agent.tools, // Debug (owner, 2026-10-04): teacher/prober raw provider events under @@ -468,11 +513,12 @@ export async function handleChatInput( // run's first LLM call (the native replacement for continuation // coalescing). Settlement still cannot beat the flow below: it needs // 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 // JSONL event stream with the record's turns; no-op unless // LH_PROVIDER_EVENTS=1. - providerEventNote(chatId, "turn", `run start (base: ${merged.length} messages)`); + providerEventNote(chatId, "turn", `run start (base: ${agentMessages.length} messages)`); const handle = runPiTurn({ agent: constructed, requestCycle, @@ -496,10 +542,26 @@ export async function handleChatInput( console.error(`[chat ${chatId}] turn error:`, error); 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) => { - // Wave B: the record write is the agent's own final state — - // the new messages are the slice past the merged base. + // Wave B: append only messages past the temporary agent prefix. + // Retry's saved record still includes its earlier failed attempts. 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 // refolds this on the client after the turn settles. The // provenance message is the turn's FIRST new assistant message. @@ -545,7 +607,7 @@ export async function handleChatInput( // resolve; stranded markers return to pending), then let // the pump take any stragglers down the idle wake. unregisterLiveAgent(chatId); - settleSteers(chatId, stamped); + settleSteers(chatId, stamped, false, constructed); // Composer steers (2026-10-03): delivered ids dropped, stranded // ones stay parked for the chat's next run (the registry is the // stranding safety net — the agent's undrained copy died above). diff --git a/src/server/chats.ts b/src/server/chats.ts index f90d950..857d3d1 100644 --- a/src/server/chats.ts +++ b/src/server/chats.ts @@ -1,10 +1,13 @@ import { abortExtraction, extractionStatuses } from "../agent/subagents/extraction.ts"; +import { isJobLive } from "../agent/subagents/aborts.ts"; import { coverageOf } from "../core/coverage.ts"; import { deleteChatTraces, type ChatDeleteMode, type ChatDeletionStats } from "../core/graph-deletion.ts"; import { titleFromPrompt } from "../core/contracts.ts"; import { graphRevision } from "./graph.ts"; import { stores } from "./stores.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*, @@ -65,10 +68,7 @@ export type ChatDeleteOutcome = * the wind-down finds the chat gone at its merge and aborts with * nothing merged. */ -export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): Promise { - if (!stores.chatStore.getChat(chatId)) { - return { ok: false, status: 404, code: "not_found", error: "No such session" }; - } +function runningWorkBlocker(chatId: string): Extract | null { if (isTurnRunning(chatId)) { return { ok: false, @@ -78,7 +78,11 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P 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) { return { ok: false, @@ -88,6 +92,15 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P jobIds: runningJobs.map((job) => job.id), }; } + return null; +} + +export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): Promise { + 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)) { abortExtraction(chatId); // true cancellation — spend stops, nothing merges 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: // ledgers → jobs → the chat record. const stats = deleteChatTraces(stores.graphStore, chatId, mode); 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() }; } diff --git a/src/server/compaction.ts b/src/server/compaction.ts index cc62aa6..865f9ca 100644 --- a/src/server/compaction.ts +++ b/src/server/compaction.ts @@ -69,7 +69,7 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s totalTokens: 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 // engine — owner ruling), so the role label never resolves an // engine; "researcher" is the least-wrong existing ModelRole label @@ -83,6 +83,12 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s 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 }; } @@ -201,7 +207,14 @@ async function compact(deps: CompactionDeps, chatId: string): Promise { spanStart = prevKept; 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 prompt = buildCompactionPrompt({ diff --git a/src/server/index.ts b/src/server/index.ts index 49968ca..a04dfb5 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -43,4 +43,5 @@ export * from "./compaction.ts"; export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts"; export * from "./observability.ts"; export * from "./model-catalog.ts"; +export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts"; export { sessionCost } from "./pricing.ts"; diff --git a/src/server/jobs.ts b/src/server/jobs.ts index 7424d41..f4b2cd2 100644 --- a/src/server/jobs.ts +++ b/src/server/jobs.ts @@ -1,7 +1,7 @@ /** * THE WAKE PIPELINE (contracts.ts "THE ASYNC PROTOCOL" §4) — server-side * 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. * * deliverJobResult(steer) — the runner calls this on job completion. @@ -40,15 +40,17 @@ * * settleSteers(chatId, persisted) — the handler calls this at turn * 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) * return to pending and the pump restarts — no stranding, no loss. * - * Crash recovery (recoverStaleJobs): a process restart can orphan jobs — - * "running" with a result (the wake never fired) or without one (the - * sub-agent loop itself died). The sweep re-delivers finished results - * (idempotently) and re-spawns unfinished loops, both via injected - * seams. It runs lazily off the /api/jobs poll, throttled. + * Worker status ends at computation completion; JobRecord.delivery owns + * the pending result or abort notice independently. Registry entries + * survive in-flight wakes, so duplicate submissions share one promise. + * Failed starts stay pending without timers or worker errors. Recovery + * 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 * 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 { steer: SteerMessage; + promise: Promise; + /** 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; reject: (reason: Error) => void; /** 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; } -/** Dock bookkeeping: the marker reached the learner's view — flip its job - * NOW instead of waiting for the delivering turn to settle. Called from - * BOTH visibility seams: the marker's message_end wire event (live steer - * injection — the card is on screen while the teacher keeps teaching) and - * the write-ahead persist in handleChatInput (the wake's input appends — - * the card is durable in the record). The registry's delivery promise - * 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 - * delivered subagent leaves the background dock the moment its result is - * visible, not when the teacher's turn ends. */ -export function noteSteerVisible(steer: SteerMessage): void { - finalizeSteer(steer); +/** Call only after canonical conversation persistence. Streaming visibility + * is not durable acceptance. The saved marker wins over delivery-bookkeeping + * failures; acknowledging delivery never changes the worker's outcome. */ +export function noteSteersDurable(chatId: string, persisted: readonly LhMessage[]): void { + for (const message of persisted) { + if (message.role === "custom") finalizeSteer(message); + } + for (const entry of [...(state().queues.get(chatId) ?? [])]) { + if (persisted.some((message) => message.id === entry.steer.id)) completeEntry(chatId, entry); + } +} + +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. */ @@ -186,24 +215,30 @@ function finalizeSteer(steer: SteerMessage): void { if (jobId) finalizeJob(jobId); } -/** Fail a steer's job on unrecoverable delivery failure — job steers only. */ -function failSteer(steer: SteerMessage, err: Error): void { - const jobId = jobIdOf(steer); - if (jobId) failJob(jobId, err); +/** Queue one logical delivery per ID, including in-flight wake cargo. + * Job promises resolve on durable marker acceptance (or session deletion); + * failed starts stay pending. Non-job probe handoffs keep their existing + * wake-acceptance/error semantics. Workers never await this promise. */ +export function deliverJobResult(steer: SteerMessage): Promise { + 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. - * Resolves when the wake turn has completed; rejects when delivery fails - * unrecoverably. Never rejects for queueing itself. */ -export function deliverJobResult(steer: SteerMessage): Promise { - return new Promise((resolve, reject) => { - const s = state(); - const chatId = chatIdOf(steer); - const queue = s.queues.get(chatId) ?? []; - queue.push({ steer, resolve, reject }); - s.queues.set(chatId, queue); - void pump(chatId); - }); +function queueSteer(chatId: string, steer: SteerMessage): QueuedSteer { + const queue = state().queues.get(chatId) ?? []; + const existing = queue.find((entry) => entry.steer.id === steer.id); + if (existing) return existing; + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + // Recovery/registration may own an entry with no waiting caller. + void promise.catch(() => undefined); + const entry: QueuedSteer = { steer, promise, resolve, reject }; + queue.push(entry); + state().queues.set(chatId, queue); + return entry; } /** 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. if (automaticFollowUpAllowed(chatId, agent) && queue && queue.length > 0) { 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); 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 * beat the registration: settlement needs a streamed response, which * 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): void { 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); } @@ -326,7 +366,7 @@ export function restoreUncommittedAutomatic(chatId: string, agent: Agent, automa * unregisterLiveAgent, with the run's persisted messages. Two jobs: * * - 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 * stopper exited before the follow-up poll; an abort; a delivery that * 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 * 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). */ -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); if (!queue || queue.length === 0) return; 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); entry.resolve(); } 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); } } @@ -373,16 +417,10 @@ export interface AbortOutcome { * runner registered it at loop start) — the in-flight model call * aborts, not just the next loop check. The runner unwinds silently * (its catch checks abortSignal.aborted; finishJob checks status). - * 2. DROP any queued result steer for this job (the delivering-result - * phase): resolve its entry without firing the wake. If the wake - * turn is ALREADY in flight (the pump shifted the entry), it's too - * late — the result steer lands, and the cancellation steer dedupes - * 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. + * 2. REFUSE if computation already finished. Queued results belong to + * the session, not to an abortable background worker. + * 3. SAVE error "Aborted by learner" and a pending abort delivery together. + * Delivery acknowledgement never overwrites the worker's outcome. * 4. STEER the teacher: a one-line abort note (buildAbortSteer) through * the same wake pipeline, so the teacher's promise of a result * doesn't hang silently in the lesson. @@ -394,28 +432,18 @@ export function abortJob(jobId: string): AbortOutcome { const job = stores.jobStore.getJob(jobId); if (!job) return { ok: false, reason: "not_found" }; if (job.status !== "running") return { ok: false, reason: "not_running" }; - - fireJobAbort(jobId); // no-op when the loop already returned (delivering) - - // 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(); - } + if (job.result) { + normalizeLegacyResult(job); + return { ok: false, reason: "not_running" }; } - 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(() => { - /* deliverJobResult marks the job error on unrecoverable failure */ + /* The saved pending abort notice remains owned by the session. */ }); return { ok: true }; } @@ -470,18 +498,9 @@ async function pump(chatId: string): Promise { // Idempotent delivery: the transcript already carries these steers // (an in-run injection persisted, a wake completed, or a duplicate // deliver raced in). Resolve and flip without re-firing anything. - const stillPending = queue.filter((e) => !steerDelivered(record.messages, e.steer.id)); - for (const entry of queue) { - if (!stillPending.includes(entry)) { - finalizeSteer(entry.steer); - entry.resolve(); - } - } - if (stillPending.length === 0) { - s.queues.delete(chatId); - return; - } - s.queues.set(chatId, stillPending); + noteSteersDurable(chatId, record.messages); + const stillPending = (s.queues.get(chatId) ?? []).filter((entry) => !entry.waking && !entry.deferred); + if (stillPending.length === 0) { 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 @@ -526,28 +545,17 @@ async function pump(chatId: string): Promise { // The runner is required — chat-handler sets it at init. If some // module instance missed the wiring, fail loudly rather than hang. if (!s.turnRunner) { - s.queues.delete(chatId); const err = new Error("wake pipeline: no turn runner wired (setTurnRunner)"); - for (const entry of stillPending) { - failSteer(entry.steer, err); - entry.reject(err); - } + for (const entry of stillPending) deferOrReject(chatId, entry, err); + parked = true; return; } // A live run may have delivered some markers while the gates were // closed — re-check: only still-missing markers ride the wake. - const fire = stillPending.filter((e) => !steerDelivered(fresh.messages, e.steer.id)); - for (const entry of stillPending) { - if (!fire.includes(entry)) { - finalizeSteer(entry.steer); - entry.resolve(); - } - } - if (fire.length === 0) { - s.queues.delete(chatId); - return; - } + noteSteersDurable(chatId, fresh.messages); + const fire = (s.queues.get(chatId) ?? []).filter((entry) => !entry.waking && !entry.deferred && !entry.handedTo); + if (fire.length === 0) return; // THE DISMISSAL PAUSE (owner design 2026-10-03, "A2"): the record // ends in a user dismissal — the session waits for the USER. Hold @@ -561,50 +569,37 @@ async function pump(chatId: string): Promise { return; } - // DEQUEUE-AT-FIRE (the Wave C lesson from the Wave B regression): - // the carried markers leave the registry BEFORE the turn runs — - // the wake run's OWN follow-up poll must never see its own cargo, - // and a 409 (client raced in) must be able to push them back. - s.queues.delete(chatId); + // Keep ownership through the wake. The flag excludes cargo from + // its own live-agent handoff while duplicate callers share it. + for (const entry of fire) entry.waking = true; const steers = fire.map((e) => e.steer); - // Fire the wake (every marker as the turn's input appends). A 409 = - // a client turn raced in between idle and firing — push the cargo - // back and retry after backoff (the racing turn's own settlement - // re-pumps if it beats us to the re-queue). + // Fire the wake with cargo still registered. A 409 releases its + // vehicle flag and retries after the existing backoff, without + // appending another registry entry. 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); return -1; }); - if (status === 403) { - // Consent may have changed after the last gate. Preserve completed - // job/result ownership and wait for an explicit connection change. - const back = s.queues.get(chatId) ?? []; - back.push(...fire); - s.queues.set(chatId, back); - parked = true; - return; - } - if (status === 409) { - const back = s.queues.get(chatId) ?? []; - back.push(...fire); - s.queues.set(chatId, back); + const saved = stores.chatStore.getChat(chatId); + if (saved) noteSteersDurable(chatId, saved.messages); + const owned = fire.filter((entry) => s.queues.get(chatId)?.includes(entry)); + if (status === 403 || status === 409) { + for (const entry of owned) { entry.waking = false; entry.wakeAgent = undefined; } + if (status === 403) { parked = true; return; } await delay(RETRY_BACKOFF_MS[Math.min(attempt++, RETRY_BACKOFF_MS.length - 1)]!); continue; } attempt = 0; - - if (status === 200) { - for (const entry of fire) { - finalizeSteer(entry.steer); - entry.resolve(); - } - } else { - const err = new Error(`wake turn failed with status ${status}`); - for (const entry of fire) { - failSteer(entry.steer, err); - entry.reject(err); + for (const entry of owned) { + if (status === 200 && !jobIdOf(entry.steer)) { + // Preserve the non-job probe handoff's acceptance semantics. + completeEntry(chatId, entry); + } else if (status !== 200 || !isTurnRunning(chatId)) { + deferOrReject(chatId, entry, new Error(`wake turn did not persist steer (status ${status})`)); } + // An accepted live turn still owns its cargo until canonical save + // or settlement. HTTP 200 alone is not a durable marker receipt. } } } finally { @@ -614,7 +609,7 @@ async function pump(chatId: string): Promise { // a live run are NOT stragglers (settleSteers owns their resurrection) // — restarting for them would just park in waitForIdle forever. 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 finalizeJob(jobId: string): void { - const job = stores.jobStore.getJob(jobId); - if (!job || job.status !== "running") return; - const result = job.result; - const failed = result ? isSubagentFailure(job.kind, result as never) : true; - stores.jobStore.updateJob(jobId, { - status: failed ? "error" : "done", - activity: failed ? "failed" : "delivered", - }); +function deferOrReject(chatId: string, entry: QueuedSteer, error: Error): void { + entry.waking = false; + entry.wakeAgent = undefined; + if (jobIdOf(entry.steer)) { + entry.deferred = true; + console.error(`[steer ${entry.steer.id}] delivery remains pending:`, error.message); + } else { + removeEntry(chatId, entry); + entry.reject(error); + } } -function failJob(jobId: string, err: Error): void { - // An aborted job stays aborted even when its (abort) steer - // delivery fails — the user's action is the terminal fact, not the - // delivery error. Non-running records are never overwritten here. - const job = stores.jobStore.getJob(jobId); - if (!job || job.status !== "running") return; - stores.jobStore.updateJob(jobId, { status: "error", error: err.message, activity: "delivery failed" }); +/** Acknowledge session delivery without changing the worker outcome. */ +function finalizeJob(jobId: string): void { + try { + let job = stores.jobStore.getJob(jobId); + if (!job) return; + if (!job.delivery) job = normalizeLegacyResult(job); + 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 { for (;;) { 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; if (!blocked) { await delay(GRACE_MS); @@ -699,9 +725,9 @@ const SWEEP_THROTTLE_MS = 30_000; * hung-but-alive loops beside themselves — the figure-maker incident, * 2026-09-18). A record WITH a live registration is provably executing * in THIS process — never touched, however old: a hung first model call - * (zero usage ever billed) stays honestly "running" in the dock until - * END kills it. The abort registration spans loop start to delivery - * completion, so a live registration can never belong to a dead run. + * stays honestly running until END kills it. Terminal workers with known + * pending delivery are restored independently; old terminal records without + * delivery metadata are never inferred to owe a delivery. */ /** 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). */ @@ -713,18 +739,14 @@ export function recoverStaleJobs(): void { try { for (const chat of stores.chatStore.listChats()) { for (const job of stores.jobStore.listJobs(chat.id)) { - if (job.status !== "running") continue; - if (isJobLive(job.id)) continue; // provably executing here — never re-run beside it + if (isJobLive(job.id)) continue; // never re-run beside live computation if (!stores.chatStore.getChat(job.chatId)) continue; - if (isTurnRunning(job.chatId)) continue; // a wake turn may be delivering it right now - if (job.result) { - // The wake never fired (or crashed mid-flight): re-deliver. - const steer = buildSubagentSteer(job, job.result as never); - stores.jobStore.updateJob(job.id, { activity: "recovering: re-delivering" }); - void deliverJobResult(steer).catch(() => { - /* deliverJobResult marks the job error on failure */ - }); - } else { + if (isTurnRunning(job.chatId)) continue; + const recovered = normalizeLegacyResult(job); + if (recovered.delivery?.status === "pending") { + restorePendingDeliveries(job.chatId); + void pump(job.chatId); + } else if (job.status === "running" && !job.result) { // The sub-agent loop itself died with the process: re-spawn. if (!s.respawn) continue; stores.jobStore.updateJob(job.id, { activity: "recovering: re-running" }); diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index b6c34e2..d2d7e91 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -248,10 +248,17 @@ export function validateChatSettings(settings: ChatSettings): SettingsValidation 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) { const slot = settings[name]; 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); if (!verdict.ok) return verdict; } @@ -283,9 +290,8 @@ function validateSlotSpec(name: ModelSlotName, slot: { model?: { provider: strin // runtime — and the clamped value is what lands in the record. The // server is the truth: the record shows the clamped level after write. -/** Clamp one level against one model (identity when the model is unknown - * or the level already supported — the existence gate rejects unknown - * models separately, so this only ever sees real ones in practice). */ +/** Clamp one level against one model. Preserve the preference when metadata + * is unavailable for an unchanged saved pin; runtime clamps after reconnect. */ function clampLevelFor(provider: string, id: string, level: ThinkingLevel): ThinkingLevel { const model = connectionModel(provider, id); return model ? clampThinkingLevel(model, level) : level; diff --git a/src/server/pi/ui-stream.ts b/src/server/pi/ui-stream.ts index b75a570..40a831d 100644 --- a/src/server/pi/ui-stream.ts +++ b/src/server/pi/ui-stream.ts @@ -1,6 +1,4 @@ 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 type { ChatGPTRequestCycle } from "../../agent/chatgpt-request-cycle.ts"; @@ -79,6 +77,10 @@ export interface PiTurnSpec { * event stream itself is verbatim — provider errors ride their * message's stopReason/errorMessage through untouched). */ 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. * Receives the agent's FULL final state (native messages) — the caller * persists the slice past its own base count. */ @@ -119,11 +121,12 @@ export interface 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; let settled = false; let sawAbort = false; + let reportedErrorText: string | undefined; // THE PER-TURN STATE LOG (memory-only — the settle-only disk writes // 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 // the role wholesale is exact; revisit if prompt-updates ever land. const settleWith = () => { - const settled_ = [...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system"); - onFinish((uniquifier?.remapSettled(settled_) as AgentMessage[]) ?? settled_, sawAbort); + try { + 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) => { + if (settled) return; // THE LACAN FIX: ids uniquify HERE, before any view — the one seam // every consumer downstream shares. No uniquifier → verbatim. const uniqued = uniquifier ? uniquifier.event(event) : event; @@ -239,15 +253,11 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle { if (event.type === "message_end") { const m = event.message as { role?: string; stopReason?: string } | undefined; 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") { - closeAll(); + // Persist/report before the terminal closes every view to new frames. settleWith(); + closeAll(); } }; requestCycle?.bindRelease(handle); @@ -273,11 +283,15 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle { // stream as stopReason "error"): surface, close, persist. If the // agent already settled (agent_end reached before the throw), // 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); - send({ type: "error", errorText: onError?.(err) ?? "An error occurred." }); - closeAll(); + reportedErrorText = onError?.(err) ?? "An error occurred."; + send({ type: "error", errorText: reportedErrorText }); settleWith(); + closeAll(); } })(); diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index 2cf1050..9b1a173 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -19,12 +19,14 @@ import type { BackgroundModelRole, CompactionSettings, GeneralSettings, + DictationSettings, GraphSettings, ModelsSettings, RoleModelOverride, } from "../core/settings.ts"; import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts"; import { WRITABLE_TOOL_KEYS } from "../core/pending.ts"; +import { isDictationLanguage } from "../core/dictation.ts"; /** Parsed-or-rejected — `ok: false` carries the 400's error message. */ export type SettingsBodyResult = @@ -375,6 +377,15 @@ export function parseCompactionSettingsBody(body: unknown): SettingsBodyResult { + const raw = asRecord(body); + if (!raw) return { ok: false, error: "Invalid JSON body" }; + if (raw.language === undefined) return { ok: true, settings: {} }; + if (!isDictationLanguage(raw.language)) return { ok: false, error: "Unsupported dictation language" }; + return { ok: true, settings: { language: raw.language } }; +} + /** The section→parser map for PUT /api/settings/[section] (the route also * carries the special prompts sub-resource). */ export const SETTINGS_SECTIONS = { @@ -385,6 +396,7 @@ export const SETTINGS_SECTIONS = { graph: parseGraphSettingsBody, compaction: parseCompactionSettingsBody, models: parseModelsSettingsBody, + dictation: parseDictationSettingsBody, } as const; export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS; diff --git a/src/server/turn-liveness.ts b/src/server/turn-liveness.ts index 27c8cc1..b5821fc 100644 --- a/src/server/turn-liveness.ts +++ b/src/server/turn-liveness.ts @@ -21,6 +21,7 @@ */ import { emit } from "../core/events.ts"; +import { clearTurnFailure } from "./turn-runs.ts"; interface LhGlobal { __lhTurnRegistry?: Map; @@ -43,15 +44,17 @@ const MAX_TURN_MS = 30 * 60_000; */ export function beginTurn(chatId: string): boolean { if (isTurnRunning(chatId)) return false; + clearTurnFailure(chatId); inFlight.set(chatId, Date.now()); emit("turn-started", { chatId }); return true; } /** - * Mark the chat's turn as finished (runs in onFinish, aborted or not — - * AFTER persistTurn, so "turn-settled" guarantees a subsequent fetch reads - * durable truth). Emits only when an entry was actually present: a + * Mark the chat's turn as finished after normal settlement, or after the + * failure notice has been retained by settlement cleanup. A subsequent + * 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. */ export function endTurn(chatId: string): void { diff --git a/src/server/turn-runs.ts b/src/server/turn-runs.ts index 29bf49a..3fbf9e7 100644 --- a/src/server/turn-runs.ts +++ b/src/server/turn-runs.ts @@ -13,7 +13,9 @@ * Registration lifecycle: chat-handler registers the handle the moment * the turn starts (after the 409 guard) and its onFinish unregisters LAST * — 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 * 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 @@ -30,6 +32,7 @@ import type { PiTurnHandle } from "./pi/ui-stream.ts"; interface LhTurnRunsGlobal { __lhTurnRuns?: Map; + __lhTurnFailures?: Map; } function runs(): Map { @@ -37,6 +40,25 @@ function runs(): Map { return (g.__lhTurnRuns ??= new Map()); } +/** One small, transient notice per failed session. Never stores messages. + * Cleared on the next accepted begin, session deletion, or process exit. */ +function failures(): Map { + const g = globalThis as typeof globalThis & LhTurnRunsGlobal; + return (g.__lhTurnFailures ??= new Map()); +} + +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 * starts). One per chat by construction — the 409 guard. */ 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 * turn (raced settle, or the client checked turnInFlight a beat too * late): a same-dialect SSE response whose single chunk tells the client - * "nothing to attach to — the record is current, refetch it". Chosen as - * an EVENT (not a status code) so the reattach path consumes ONE wire + * "nothing to attach to — refetch durable truth". A retained settlement + * 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. */ -export function settledTurnResponse(): Response { - return new Response('data: {"type":"turn-settled"}\n\ndata: [DONE]\n\n', { +export function settledTurnResponse(chatId?: string): Response { + 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: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", diff --git a/src/server/turn-service.ts b/src/server/turn-service.ts index b369697..ef7728a 100644 --- a/src/server/turn-service.ts +++ b/src/server/turn-service.ts @@ -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 * implementing. Each takes its dependencies explicitly (storage ports, the * 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 { 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 * 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, * 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( chatStore: ChatStore, @@ -56,7 +65,7 @@ export function persistTurn( ): LhMessage[] { try { const record = chatStore.getChat(chatId); - if (!record) return []; + if (!record) throw new Error(`Chat not found: ${chatId}`); const stamped: LhMessage[] = []; for (const m of turnMessages.slice(baseCount)) { const message = m as LhMessage; @@ -79,7 +88,7 @@ export function persistTurn( return stamped; } catch (err) { console.error(`[chat ${chatId}] persist failed:`, err, { isAborted }); - return []; + throw new TurnPersistenceError(err); } } diff --git a/src/server/web-subscription-http.ts b/src/server/web-subscription-http.ts new file mode 100644 index 0000000..3d5d7a9 --- /dev/null +++ b/src/server/web-subscription-http.ts @@ -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 { + 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; + 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 { + 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 } }); +} diff --git a/src/server/web-subscriptions.ts b/src/server/web-subscriptions.ts new file mode 100644 index 0000000..4e40538 --- /dev/null +++ b/src/server/web-subscriptions.ts @@ -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 } +const KEY = Symbol.for("lh.webSubscriptions"); +interface Registry { + owner?: ReturnType; +} +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[0]["store"]; + sdk?: unknown; + onPacket?: (channel: Channel, packet: Packet) => void; +} ) { + const notify = options.onPacket ?? (() => {}); + const launches = new AsyncLocalStorage(); + const actions = new Map(); + 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, 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 { + 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(channel: Channel, op: string, fields: Record): Promise { + 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 }; +} diff --git a/src/store/jobs.ts b/src/store/jobs.ts index e8df50c..cea2da7 100644 --- a/src/store/jobs.ts +++ b/src/store/jobs.ts @@ -35,6 +35,7 @@ function createJob(init: CreateJobInit): JobRecord { kind: init.kind, chatId: init.chatId, toolCallId: init.toolCallId, + ...(init.input !== undefined ? { input: init.input } : {}), status: "running", activity: init.activity ?? "starting", createdAt: now, diff --git a/tests/automatic-updates.test.ts b/tests/automatic-updates.test.ts new file mode 100644 index 0000000..5875a7e --- /dev/null +++ b/tests/automatic-updates.test.ts @@ -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 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); + }); +}); diff --git a/tests/byok-refusal.test.ts b/tests/byok-refusal.test.ts index fab7db2..f942d68 100644 --- a/tests/byok-refusal.test.ts +++ b/tests/byok-refusal.test.ts @@ -1,6 +1,9 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { parseModelsRefusal, parseProviderKeyRefusal } from "../components/chat.tsx"; +import { parseModelsRefusal, parseProviderKeyRefusal, retryFailedResponse } from "../components/chat.tsx"; +import { SessionModel } from "../components/session-core.ts"; +import { fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { newUserMessage, type LhMessage } from "../src/core/native-messages.ts"; import { prefillFromSlots, prefillLevelFromPin, setupStage } from "../components/setup-dialog.tsx"; import { isModelsRefusal, @@ -39,6 +42,64 @@ describe("parseModelsRefusal — the send refusal detector", () => { }); }); +describe("Retry owns no composer recovery draft", () => { + for (const envelope of [providerKeyMissing("openrouter", ["strong"]), modelsNotConfigured(["strong"])]) { + it(`accepted A / failed model / unsent B / Retry ${envelope.code} retires only A's fallback`, async () => { + const durable: LhMessage[] = [ + newUserMessage("Accepted text A"), + { ...fauxAssistantMessage("Partial reply", { stopReason: "error", errorMessage: "Model failed" }), id: "failed-A" }, + ]; + const submittedDraft: { current: string | null } = { current: "Accepted text A" }; + let composer = "Unsent text B"; + const body = JSON.stringify(envelope); + const model = new SessionModel({ + chatId: "fixture", initialMessages: durable, + fetch: async (_url, init) => { + assert.equal(submittedDraft.current, null, "retire A before Retry can reach a refusal"); + assert.deepEqual(JSON.parse(String(init?.body)).input, { type: "retry" }); + return new Response(body, { status: 503 }); + }, + }); + let request!: ReturnType; + retryFailedResponse((input) => request = model.send(input), submittedDraft); + await request; + assert.equal(model.getState().error?.message, body, "503 stays raw for existing setup/picker routing"); + assert.ok(parseModelsRefusal(body) || parseProviderKeyRefusal(body)); + // The existing refusal effect restores only an owned submitted + // draft. Retry must leave this slot empty, not replace B with A. + if (submittedDraft.current !== null) composer = submittedDraft.current; + assert.equal(composer, "Unsent text B"); + assert.deepEqual(model.getState().nativeMessages, durable, "Retry adds no optimistic learner message"); + }); + } + + it("does not clear a later ordinary send's recovery draft when Retry settles", async () => { + const submittedDraft: { current: string | null } = { current: "Accepted text A" }; + let finish!: () => void; + const pending = new Promise((resolve) => { finish = resolve; }); + retryFailedResponse(async () => pending, submittedDraft); + assert.equal(submittedDraft.current, null); + submittedDraft.current = "Later ordinary text C"; + finish(); + await pending; + await Promise.resolve(); + assert.equal(submittedDraft.current, "Later ordinary text C", "no asynchronous cleanup may retire a newer owner"); + }); + + it("ordinary text refusals still keep their submitted draft for recovery", async () => { + const submittedDraft: { current: string | null } = { current: "Ordinary text C" }; + const body = JSON.stringify(providerKeyMissing("openrouter", ["strong"])); + const model = new SessionModel({ + chatId: "fixture", initialMessages: [], + fetch: async () => new Response(body, { status: 503 }), + }); + await model.send({ type: "text", text: submittedDraft.current! }); + assert.equal(submittedDraft.current, "Ordinary text C"); + assert.equal(model.getState().error?.message, body); + assert.equal(parseProviderKeyRefusal(model.getState().error?.message), true); + }); +}); + describe("isModelsRefusal — the job-record matcher", () => { it("matches the runner's composed wording, not real failures", () => { assert.equal(isModelsRefusal(`${modelsNotConfigured(["vision"]).error} (missing: vision)`), true); @@ -123,9 +184,9 @@ describe("prefillFromSlots — the SetupDialog prefill (BYOK wave)", () => { 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 }); - assert.deepEqual(pre, {}); + assert.deepEqual(pre, { strong: { provider: "gone", id: "vanished" } }); }); it("provider collisions never cross-pollute (same id, different provider)", () => { @@ -158,9 +219,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", () => { - assert.equal(setupStage(null), "keys"); + assert.equal(setupStage(null), "loading"); assert.equal(setupStage([{ id: "x", keyPresent: false }]), "keys"); assert.equal(setupStage([{ id: "x", keyPresent: true }]), "pickers"); }); diff --git a/tests/chat-flow.test.ts b/tests/chat-flow.test.ts index ed25fcc..a1f3bca 100644 --- a/tests/chat-flow.test.ts +++ b/tests/chat-flow.test.ts @@ -30,13 +30,14 @@ import { createTeacherTools } from "../src/agent/tools.ts"; import { handleChatInput, handleChatRequest } from "../src/server/chat-handler.ts"; import { effectiveSettings } from "../src/server/stores.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 { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts"; import { chatStore } from "../src/store/chats.ts"; import { jobStore } from "../src/store/jobs.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 type { PiEngine } from "../src/agent/models.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). const dismissRoute = await import("../app/api/chats/[id]/dismiss/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"; // 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" }); jobIdMid = job.id; // 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 delivery = deliverJobResult(steer); await new Promise((r) => setTimeout(r, 300)); // vehicle 1 hands off @@ -531,7 +534,7 @@ describe("teacher chat flow (scripted model)", () => { release2(); await drain(await res); 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 // 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[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)", () => { it("one turn per chat: a concurrent request is 409'd; the next one after finish is accepted", async () => { const { engine } = scriptedTeacher(defaultLesson); diff --git a/tests/chatgpt-auth.test.ts b/tests/chatgpt-auth.test.ts index fb09716..1d23c22 100644 --- a/tests/chatgpt-auth.test.ts +++ b/tests/chatgpt-auth.test.ts @@ -6,6 +6,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import type { ChatGPTStatus } from "../src/core/contracts/chatgpt.ts"; +import { completedWithin } from "./completion.ts"; const require = createRequire(import.meta.url); const { createChatGPT, fileStore, allowedAuthEndpoint, SCOPES } = require("../electron/chatgpt.cjs"); const { privateKey, publicKey } = await generateKeyPair("RS256"); @@ -445,20 +447,50 @@ test("scope-disabled credentials remain revocable and unconfirmed revoke never c } finally { f.service.close(); } }); -test("repeated real verified sign-in creates one visible account while preserving separately bound registrations until explicit forgetting", async () => { - const f = await loggedIn(); +for (const holdVerification of [false, true]) test(`repeated real verified sign-in creates one visible account while preserving separately bound registrations until explicit forgetting${holdVerification ? " (held JWKS regression)" : ""}`, { timeout: 10000 }, async () => { + const repeatComplete = deferred(); + const verificationEntered = deferred(), verificationRelease = deferred(); + let repeatArmed = false, sawPending = false, completed = false; + const f = await loggedIn({ + change: (push) => { + if (!repeatArmed) return; + if (push.status.pending) sawPending = true; + else if (sawPending) { completed = true; repeatComplete.resolve(push.status); } + }, + provider: async (url) => { + if (holdVerification && repeatArmed && url.endsWith("jwks.json")) { + verificationEntered.resolve(); await verificationRelease.promise; + } + return undefined; + }, + }); try { const original = f.saved.registrations[0]; - await f.service.login(); await f.callback({ client_id: "oaiapp_repeat" }); - for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((r) => setImmediate(r)); - const status = await f.service.status(); + const writesBeforeRepeat = f.writeCount; + // Initial login already settled fixture.changed; arm for this attempt's terminal notification. + repeatArmed = true; + await f.service.login(); assert.equal(sawPending, true); + assert.equal((await f.callback({ client_id: "oaiapp_repeat" })).status, 200); + if (holdVerification) { + await verificationEntered.promise; + assert.equal(completed, false, "HTTP acknowledgement cannot complete pending identity verification"); + const held = await f.service.status(); + assert.equal(held.pending, true); assert.equal(held.error, null); + assert.equal(held.activeId, original.id); assert.equal(held.accounts.length, 1); + assert.deepEqual(f.saved.registrations, [original]); + assert.equal(f.writeCount, writesBeforeRepeat, "unverified repeat sign-in cannot persist credentials"); + } + verificationRelease.resolve(); + const status = await repeatComplete.promise; + assert.equal(status.pending, false); assert.equal(status.error, null); + assert.equal(f.writeCount, writesBeforeRepeat + 1, "repeat sign-in completes after durable credential persistence"); assert.equal(f.saved.registrations.length, 2); assert.equal(status.accounts.length, 1); assert.equal(status.activeId, status.accounts[0].id); assert.notEqual(status.activeId, original.id); assert.equal(f.saved.registrations[0].clientId, original.clientId); assert.equal(f.saved.registrations[1].clientId, "oaiapp_repeat"); await f.service.disconnect(status.activeId); assert.deepEqual(f.saved.registrations, []); assert.equal((await f.service.status()).accounts.length, 0); assert.equal(f.requests.filter((r) => r.form?.has("token_type_hint")).length, 2); - } finally { f.service.close(); } + } finally { verificationRelease.resolve(); f.service.close(); } }); test("verified identity grouping hides legacy duplicate registrations without merging client credentials or matching emails", async () => { @@ -581,10 +613,17 @@ test("disconnect persistence failure is not reported as local forgetting and lea } finally { f.service.close(); } }); -test("one app-wide background preference defaults ON over legacy OFF, persists with zero accounts, and never changes on auth/account operations", async () => { +for (const holdToken of [false, true]) test(`one app-wide background preference defaults ON over legacy OFF, persists with zero accounts, and never changes on auth/account operations${holdToken ? " (completion held beyond old wait budget)" : ""}`, async () => { const first = await loggedIn(); first.service.close(); const legacy = structuredClone(first.saved); legacy.registrations[0].background = false; - const f = fixture({ state: legacy }); + const tokenEntered = deferred(), releaseToken = deferred(); + let completion = deferred(); + const change = ({ status }: { status: ChatGPTStatus }) => { if (!status.pending) completion.resolve(status); }; + const f = fixture({ state: legacy, change, token: async (form, url) => { + tokenEntered.resolve(); + if (holdToken) await releaseToken.promise; + return Response.json({ access_token: "scratch-oauth-token", refresh_token: "scratch-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") }); + } }); try { assert.equal((await f.service.status()).allowBackgroundUse, true, "absent new setting ignores legacy per-account OFF"); await f.service.lease(true); @@ -594,21 +633,43 @@ test("one app-wide background preference defaults ON over legacy OFF, persists w await f.service.lease(false); const id = f.saved.activeId; await f.service.select(id); assert.equal((await f.service.status()).allowBackgroundUse, false); - await f.service.login(id); await f.callback(); - for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((r) => setImmediate(r)); + // A fresh signal per attempt: the fixture's original changed promise + // was already resolved by earlier account/consent notifications. + completion = deferred(); + await f.service.login(id); + const response = await f.callback(); assert.equal(response.status, 200); await response.text(); + const waiting = completedWithin(completion.promise, "returning verified sign-in"); + let settled = false; void waiting.then(() => { settled = true; }, () => { settled = true; }); + if (holdToken) { + await completedWithin(tokenEntered.promise, "held token request"); + // Regression only: exhaust the OLD wait while real completion is + // explicitly held, rather than relying on machine speed or sleeps. + for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((r) => setImmediate(r)); + assert.equal((await f.service.status()).pending, true); + assert.equal(f.saved.registrations[0].background, false, "the old assertion would run before replacement was saved"); + assert.equal(settled, false, "the corrected wait must still await completion"); + releaseToken.resolve(); + } + const status = await waiting; + assert.equal(status.pending, false); assert.equal(status.error, null, "reconnecting must actually succeed"); + assert.equal(status.activeId, id); assert.equal(status.accounts[0]!.connected, true); assert.equal(f.saved.allowBackgroundUse, false, "reconnecting cannot overwrite app preference"); assert.equal(f.saved.registrations[0].background, undefined, "new verified registrations omit obsolete per-account preference"); await f.service.disconnect(f.saved.activeId); assert.deepEqual(f.saved.registrations, []); assert.equal(f.saved.allowBackgroundUse, false); - const restarted = fixture({ state: f.saved }); + const restarted = fixture({ state: f.saved, change }); try { assert.equal((await restarted.service.status()).allowBackgroundUse, false, "zero-account restart preserves global OFF"); - await restarted.service.login(); await restarted.callback(); - for (let i = 0; i < 100 && (await restarted.service.status()).pending; i++) await new Promise((r) => setImmediate(r)); + completion = deferred(); + await restarted.service.login(); + const response = await restarted.callback(); assert.equal(response.status, 200); await response.text(); + const status = await completedWithin(completion.promise, "verified sign-in after zero-account restart"); + assert.equal(status.pending, false); assert.equal(status.error, null); + assert.notEqual(status.activeId, null); assert.equal(status.accounts[0]!.connected, true); assert.equal(restarted.saved.allowBackgroundUse, false); await restarted.service.consent(true); assert.equal(restarted.saved.allowBackgroundUse, true); } finally { restarted.service.close(); } - } finally { f.service.close(); } + } finally { releaseToken.resolve(); f.service.close(); } }); test("private leases bind active account, authority revision and raw visible model membership", async () => { diff --git a/tests/chatgpt-background.test.ts b/tests/chatgpt-background.test.ts index 7bdf602..02bf717 100644 --- a/tests/chatgpt-background.test.ts +++ b/tests/chatgpt-background.test.ts @@ -43,7 +43,7 @@ for (const revokedAfterQueue of [false, true]) { 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 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 contexts: string[] = []; 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); await new Promise((resolve) => setImmediate(resolve)); 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); connection(true); await delivered; diff --git a/tests/chatgpt-connection-ui.test.tsx b/tests/chatgpt-connection-ui.test.tsx index f5f8f74..2edcf2f 100644 --- a/tests/chatgpt-connection-ui.test.tsx +++ b/tests/chatgpt-connection-ui.test.tsx @@ -12,6 +12,7 @@ import { SettingsDialogBody } from "../components/settings/settings-dialog.tsx"; import { KeysSection } from "../components/settings/keys-section.tsx"; import { ModelControl } from "../components/model-picker.tsx"; import type { ChatGPTStatus, ChatGPTControlBridge } from "../src/core/index.ts"; +import { completedWithin } from "./completion.ts"; const dom = parseHTML(""); Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, localStorage: { getItem: () => null, setItem() {} } }); @@ -23,7 +24,7 @@ const connected = (allowBackgroundUse = true): ChatGPTStatus => ({ ...disconnect const storageError = "ChatGPT credentials could not be read or saved. Check local file access, then reconnect before making requests."; const warning = "Account disconnected locally. Remote revocation could not be confirmed."; const errorReply = () => ({ error: storageError } as ChatGPTStatus); -function deferred() { let resolve!: (v: T) => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } +function deferred() { let resolve!: (v: T) => void; let reject!: (error: unknown) => void; const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); return { promise, resolve, reject }; } function fixture(initial = disconnected(), overrides: Partial = {}) { let status = initial; const calls = { login: [] as (string | undefined)[], cancel: 0, consent: [] as boolean[], disconnect: [] as string[], select: [] as string[], logout: 0 }; @@ -494,26 +495,57 @@ test("canceled attempt's late reply cannot release a newer pending attempt or ap await act(async () => f.complete()); assert.deepEqual(f.calls.consent, []); assert.equal(f.status.allowBackgroundUse, true); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); }); -test("real main/control bridge: immediate pending, actual cancellation, verified callback, then explicit global draft persistence", async () => { +for (const holdSave of [false, true]) test(`real main/control bridge: immediate pending, actual cancellation, verified callback, then explicit global draft persistence${holdSave ? " (completion held beyond old wait budget)" : ""}`, async () => { const require = createRequire(import.meta.url); const { createChatGPT, SCOPES } = require("../electron/chatgpt.cjs"); const { chatGPTControl } = require("../electron/chatgpt-ipc.cjs"); const { privateKey, publicKey } = await generateKeyPair("RS256"); const jwk = { ...await exportJWK(publicKey), kid: "ui-fixture", alg: "RS256", use: "sig" }; - let saved: any = null, authorize!: URL, tokenCalls = 0; const listeners = new Set<() => void>(); - const service = createChatGPT({ store: { secure() {}, async read() { return saved; }, async write(s: any) { saved = structuredClone(s); } }, openExternal: async (url: string) => { authorize = new URL(url); }, onChange: () => { for (const cb of listeners) cb(); }, fetch: async (url: string, init: RequestInit) => { + const consentCompleted = deferred(), saveEntered = deferred(), releaseSave = deferred(); + let saved: any = null, authorize!: URL, tokenCalls = 0, completionArmed = false; const listeners = new Set<() => void>(); + const service = createChatGPT({ store: { secure() {}, async read() { return saved; }, async write(s: any) { + if (holdSave && s.allowBackgroundUse === false) { saveEntered.resolve(); await releaseSave.promise; } + saved = structuredClone(s); + } }, openExternal: async (url: string) => { authorize = new URL(url); }, onChange: ({ status }: { status: ChatGPTStatus }) => { + if (completionArmed && !status.pending && status.error) consentCompleted.reject(new Error(`Fixture sign-in failed: ${status.error}`)); + for (const cb of listeners) cb(); + }, fetch: async (url: string, init: RequestInit) => { if (url.endsWith("jwks.json")) return Response.json({ keys: [jwk] }); assert.equal(url, "https://auth.openai.com/api/accounts/oauth/token"); tokenCalls++; const form = init.body as URLSearchParams; const jwt = await new SignJWT({ nonce: authorize.searchParams.get("nonce"), email: "fixture@example.invalid" }).setProtectedHeader({ alg: "RS256", kid: "ui-fixture" }).setIssuer("https://auth.openai.com").setSubject("fixture-subject").setAudience(form.get("client_id")!).setIssuedAt().setExpirationTime("1h").sign(privateKey); return Response.json({ access_token: "fixture-access", refresh_token: "fixture-refresh", id_token: jwt, token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") }); } }); - window.lh = { expandWindow() {}, chatgpt: { status: () => chatGPTControl({ op: "status" }, service), login: (id) => chatGPTControl({ op: "login", id }, service), cancel: () => chatGPTControl({ op: "cancel" }, service), select: (id) => chatGPTControl({ op: "select", id }, service), disconnect: (id) => chatGPTControl({ op: "disconnect", id }, service), logout: (id) => chatGPTControl({ op: "logout", id }, service), consent: (enabled) => chatGPTControl({ op: "consent", enabled }, service), onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); } } }; + window.lh = { expandWindow() {}, chatgpt: { status: () => chatGPTControl({ op: "status" }, service), login: (id) => chatGPTControl({ op: "login", id }, service), cancel: () => chatGPTControl({ op: "cancel" }, service), select: (id) => chatGPTControl({ op: "select", id }, service), disconnect: (id) => chatGPTControl({ op: "disconnect", id }, service), logout: (id) => chatGPTControl({ op: "logout", id }, service), consent: (enabled) => { + const reply = chatGPTControl({ op: "consent", enabled }, service); + void reply.then(consentCompleted.resolve, consentCompleted.reject); + return reply; + }, onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); } } }; try { await mount(); await begin(); assert.equal((await service.status()).pending, true); await click(button("Cancel", byTest("chatgpt-account-dialog"))); assert.equal((await service.status()).pending, false); assert.equal(tokenCalls, 0); await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed")); const callback = new URL(authorize.searchParams.get("redirect_uri")!); callback.search = new URLSearchParams({ state: authorize.searchParams.get("state")!, code: "fixture-code", client_id: "oaiapp_ui_fixture" }).toString(); - await act(async () => { const response = await originalFetch(callback); await response.text(); for (let i = 0; i < 100 && (saved.allowBackgroundUse !== false || (await service.status()).pending); i++) await new Promise((r) => setImmediate(r)); }); + await act(async () => { + completionArmed = true; + // Callback acknowledgment is not verified login, nor is login completion + // the subsequent consent save. Wait for the actual control reply. + const waiting = completedWithin(consentCompleted.promise, "verified sign-in and global draft persistence"); + let settled = false; void waiting.then(() => { settled = true; }, () => { settled = true; }); + const response = await originalFetch(callback); assert.equal(response.status, 200); await response.text(); + if (holdSave) { + await completedWithin(saveEntered.promise, "held global draft save"); + // Regression only: the OLD loop returns with the save still held. + for (let i = 0; i < 100 && (saved.allowBackgroundUse !== false || (await service.status()).pending); i++) await new Promise((r) => setImmediate(r)); + assert.equal((await service.status()).pending, false, "verification finished before the consent save"); + assert.equal(saved.allowBackgroundUse, undefined, "the old assertion would run before the global draft was saved"); + assert.equal(settled, false, "the corrected wait must still await persistence"); + releaseSave.resolve(); + } + const status = await waiting; + assert.equal(status.error, null); assert.equal(status.pending, false); + assert.equal(status.allowBackgroundUse, false); assert.equal(status.accounts[0]!.connected, true); + }); assert.equal(tokenCalls, 1); assert.equal(saved.allowBackgroundUse, false); assert.equal(saved.registrations[0].background, undefined); + assert.equal(document.querySelector('[role="alert"]'), null); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert(button("Manage…")); - } finally { await act(async () => service.close()); } + } finally { releaseSave.resolve(); await act(async () => service.close()); } }); test("browser-only sign-in is disabled; foreground toolbar has no plan badge or background action", async () => { diff --git a/tests/chatgpt-dev-ipc.test.ts b/tests/chatgpt-dev-ipc.test.ts index fcb0e44..e940b01 100644 --- a/tests/chatgpt-dev-ipc.test.ts +++ b/tests/chatgpt-dev-ipc.test.ts @@ -12,7 +12,7 @@ const { GET } = await import("../app/api/models/route.ts"); const { chatGPTRequest, chatGPTConnected, chatGPTModels } = await import("../src/agent/chatgpt-bridge.ts"); 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 connected = Object.getOwnPropertyDescriptor(process, "connected"); 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(chatGPTConnected(), false); 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"); } process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64); diff --git a/tests/compaction-service.test.ts b/tests/compaction-service.test.ts index ee5b4d4..9a27a66 100644 --- a/tests/compaction-service.test.ts +++ b/tests/compaction-service.test.ts @@ -27,12 +27,14 @@ const { ensureCompacted, kickCompaction, awaitCompaction, + compactionInFlight, effectiveThresholdTokens, projectedContextTokens, } = await import("../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 { 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"; after(() => { @@ -167,12 +169,120 @@ describe("the compaction service", () => { await ensureCompacted(deps, chatId); 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.equal(seenPrompt.split(COMPACTED_HISTORY_HEADER).length - 1, 1, "the prior summary is supplied exactly once"); const record = chatStore.getChat(chatId)!; 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.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 = 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", () => { assert.equal(effectiveThresholdTokens(150_000, 200_000), 150_000); assert.equal(effectiveThresholdTokens(150_000, 100_000), 100_000 - 16_384); diff --git a/tests/compaction.test.ts b/tests/compaction.test.ts index 5139558..954ea5f 100644 --- a/tests/compaction.test.ts +++ b/tests/compaction.test.ts @@ -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"); }); + 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", () => { const kept = [assistantWithCall("a1", "call_1")]; 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)"); }); + 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", () => { const oldMarker = newCompactionMarker("OLD", { firstKeptMessageId: "m1", tokensBefore: 1, at: "t" }); const m1 = newUserMessage("x"); @@ -303,6 +335,11 @@ describe("serializeForSummary + buildCompactionPrompt", () => { 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", () => { 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( @@ -388,6 +425,11 @@ describe("toLlmMessages carries the leading system message (request chrome)", () const record = (): LhMessage[] => [ { id: "old", role: "user", content: "dropped history", timestamp: 1 }, { id: "kept", role: "user", content: "kept tail", timestamp: 2 }, + newCompactionMarker("SUPERSEDED SUMMARY", { + firstKeptMessageId: "kept", + tokensBefore: 98, + at: new Date(0).toISOString(), + }), newCompactionMarker("SUMMARY", { firstKeptMessageId: "kept", tokensBefore: 99, @@ -407,6 +449,8 @@ describe("toLlmMessages carries the leading system message (request chrome)", () : ((m.content as { text?: string }[])[0]?.text ?? ""); 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(!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)", () => { diff --git a/tests/completion.test.ts b/tests/completion.test.ts new file mode 100644 index 0000000..8e5e154 --- /dev/null +++ b/tests/completion.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { completedWithin } from "./completion.ts"; + +test("fixture completion wait returns its result and preserves its actual error", async () => { + assert.equal(await completedWithin(Promise.resolve("saved"), "fixture save"), "saved"); + const failure = new Error("fixture persistence failed"); + await assert.rejects(completedWithin(Promise.reject(failure), "fixture save"), (error) => error === failure); +}); + +test("fixture completion wait fails clearly if completion never arrives", async () => { + await assert.rejects( + completedWithin(new Promise(() => {}), "verified fixture sign-in", 0), + /Timed out after 0ms waiting for verified fixture sign-in/, + ); +}); diff --git a/tests/completion.ts b/tests/completion.ts new file mode 100644 index 0000000..b3404eb --- /dev/null +++ b/tests/completion.ts @@ -0,0 +1,15 @@ +/** Bound an actual fixture completion signal, not a count of event-loop + * turns. Always clear the watchdog, and preserve real fixture failures. */ +export async function completedWithin(completion: Promise, description: string, timeoutMs = 10_000): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + completion, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms waiting for ${description}`)), timeoutMs); + }), + ]); + } finally { + clearTimeout(timer); + } +} diff --git a/tests/content-recovery.test.ts b/tests/content-recovery.test.ts new file mode 100644 index 0000000..d8f9196 --- /dev/null +++ b/tests/content-recovery.test.ts @@ -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); + } +}); diff --git a/tests/demos.test.tsx b/tests/demos.test.tsx index 8ce0b15..d3e1665 100644 --- a/tests/demos.test.tsx +++ b/tests/demos.test.tsx @@ -6,7 +6,7 @@ * 1. bridge-script injection (buildDemoSrcDoc) + the pure bridge-message * reducer (reduceDemoBridgeState) * 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 * a fix iteration, untested-final verification, never-clean failure, * no-Chrome failure) @@ -40,7 +40,7 @@ import { INITIAL_DEMO_BRIDGE_STATE, reduceDemoBridgeState, } 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 { chartSize } from "../components/session-overlay.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…/); }); - 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( , ); 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"); }); @@ -325,7 +325,7 @@ describe("dispatch card rendering (the tool-call ack box)", () => { 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 = ({ id: "jp1", kind: "pdf", @@ -342,7 +342,7 @@ describe("dispatch card rendering (the tool-call ack box)", () => { , ); 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(" { + it("renders the verified svg as an image; the maker's notes are glue and stay hidden", () => { const svg = ''; const steer = buildSubagentSteer( { id: "jv", kind: "visual", toolCallId: "cv" }, { svg, notes: "Looking at the render, the diagram is clean and accurate." }, ); const html = renderToStaticMarkup(); - 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/); // Natural sizing (owner, 2026-10-07): a DECLARED width (200) means the // holder hugs + centers rather than stretching full-width. 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"); }); @@ -590,17 +590,8 @@ describe("setSteerUiInverted (the persisted invert switch, 2026-10-07)", () => { }); }); -describe("sanitizeSvg", () => { - it("strips script blocks, event handlers, and javascript: urls, keeps geometry", () => { - const dirty = ''; - const clean = sanitizeSvg(dirty); - assert.ok(!/
Example
Link`; +const PREFIX = "data:image/svg+xml;charset=utf-8,"; + +function assertImageBoundary(holder: Element, source: string) { + const image = holder.querySelector("img"); + assert.ok(image, "figure is an image"); + assert.equal(holder.children.length, 1, "no figure markup alongside the image"); + assert.equal(holder.querySelector("svg, style, script, foreignObject, a, [onload], [onclick]"), null, + "generated elements and CSS never enter the app document"); + assert.equal(image.getAttribute("alt"), "Figure"); + assert.equal(image.getAttribute("draggable"), "false", "native image dragging must not interfere with panning"); + const src = image.getAttribute("src")!; + assert.ok(src.startsWith(PREFIX)); + assert.equal(decodeURIComponent(src.slice(PREFIX.length)), source, "image URL contains the expected SVG display source, not a raster"); + return image; +} + +describe("SVG display compatibility", () => { + it("leaves standalone SVG unchanged, including comments and CDATA", () => { + const source = FIGURE.replace("", ']]>'); + assert.equal(svgImageSource(source), source); + }); + + it("adds only a missing root namespace, ignoring comments and attribute values", () => { + const source = '\' xmlns:xlink="http://www.w3.org/1999/xlink">'; + const expected = source.replace('Example`; + assert.equal(svgImageSource(source), source); + } + }); + + it("converts nbsp references in text and attributes, but not comments, CDATA or processing instructions", () => { + const source = 'A B &nbsp;  &nbsp;'; + const expected = source.replace('aria-label="A B"', 'aria-label="A B"').replace('A B', 'A B'); + assert.equal(svgImageSource(source), expected); + assert.equal(svgImageSource(expected), expected); + }); +}); + +describe("generated SVG image boundary", () => { + for (const mode of ["inline", "fullscreen"] as const) { + for (const [name, source] of [["reported style rule and fragment IDs", FIGURE], ["active markup", ACTIVE_MARKUP]] as const) { + it(`${mode}: isolates ${name} inside the image URL`, () => { + const html = renderToStaticMarkup(mode === "inline" + ? + : {}} />); + const { document } = parseHTML(html); + const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!; + assertImageBoundary(holder, source); + assert.ok(holder.classList.contains("bg-white"), "white backing retained"); + }); + } + + for (const [name, original, display] of [ + ["missing namespace", 'Example', 'Example'], + ["HTML entity", 'A B', 'A B'], + ] as const) { + it(`${mode}: repairs ${name} only in the image source`, () => { + const output = { svg: original }; + const html = renderToStaticMarkup(mode === "inline" + ? + : {}} />); + const { document } = parseHTML(html); + const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!; + assertImageBoundary(holder, display); + assert.equal(output.svg, original, "artifact source was not mutated"); + }); + } + + it(`${mode}: retains inversion on the white backing`, () => { + const html = renderToStaticMarkup(mode === "inline" + ? + : {}} />); + const { document } = parseHTML(html); + const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!; + assert.match(holder.getAttribute("style")!, /invert\(1\) hue-rotate\(180deg\)/); + assert.ok(holder.classList.contains("bg-white")); + assertImageBoundary(holder, FIGURE); + }); + } + + it("inline: retains natural width and only shrinks oversized figures", () => { + const { document } = parseHTML(renderToStaticMarkup()); + const image = assertImageBoundary(document.querySelector('[data-testid="visual-svg"]')!, FIGURE); + assert.equal(image.getAttribute("width"), "320"); + assert.equal(image.className, "block h-auto max-w-full"); + assert.ok(document.querySelector('[data-testid="visual-card"]')!.classList.contains("w-fit")); + }); + + for (const width of ["", ' width="100%"']) { + it(`inline: keeps the full-width fallback for ${width || "viewBox-only"} SVG`, () => { + const source = `Example`; + const { document } = parseHTML(renderToStaticMarkup()); + const image = assertImageBoundary(document.querySelector('[data-testid="visual-svg"]')!, source); + assert.equal(image.getAttribute("width"), null); + assert.equal(image.className, "block h-auto w-full"); + assert.ok(document.querySelector('[data-testid="visual-card"]')!.classList.contains("w-full")); + }); + } + + it("fullscreen: keeps the natural-size box and zoom controls", () => { + const { document } = parseHTML(renderToStaticMarkup( {}} />)); + const holder = document.querySelector("[data-lh-overlay-chart]")!; + assert.equal(assertImageBoundary(holder, FIGURE).className, "block h-auto w-full"); + assert.match(holder.parentElement!.getAttribute("style")!, /width:320px/); + for (const label of ["Zoom in", "Zoom out", "Fit diagram to view", "Close"]) { + assert.ok(document.querySelector(`button[aria-label="${label}"]`)); + } + }); +}); + +it("the fullscreen button opens the same SVG image and inversion; download keeps original bytes", async () => { + const originalSvg = FIGURE.replace(' xmlns="http://www.w3.org/2000/svg"', '').replace("θ & φ", "θ & φ"); + const displaySvg = FIGURE.replace("θ & φ", "θ & φ"); + const dom = parseHTML(""); + const names = ["window", "document", "Element", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT"] as const; + const originals = names.map((name) => Object.getOwnPropertyDescriptor(globalThis, name)); + Object.assign(globalThis, { + window: dom.window, + document: dom.document, + Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const host = dom.document.createElement("div"); + dom.document.body.append(host); + const root = createRoot(host); + const originalCreateUrl = URL.createObjectURL; + const originalRevokeUrl = URL.revokeObjectURL; + const originalCreateElement = dom.document.createElement; + let downloaded: Blob | undefined; + let revoked: string | undefined; + let clicked: { name: string; href: string } | undefined; + URL.createObjectURL = (blob) => { + assert.ok(blob instanceof Blob); + downloaded = blob; + return "blob:figure-test"; + }; + URL.revokeObjectURL = (url) => { revoked = url; }; + function Harness() { + const [focus, setFocus] = useState<{ svg: string; inverted: boolean } | null>(null); + return ( + {}, openFigure: (svg, inverted) => setFocus({ svg, inverted }) }}> + + {focus && setFocus(null)} />} + + ); + } + try { + await act(async () => root.render()); + await act(async () => { host.querySelector('[data-testid="visual-open"]')!.dispatchEvent(new dom.window.Event("click", { bubbles: true })); }); + const inline = assertImageBoundary(host.querySelector('[data-testid="visual-svg"]')!, displaySvg); + const overlayHolder = host.querySelector("[data-lh-overlay-chart]")!; + const fullscreen = assertImageBoundary(overlayHolder, displaySvg); + assert.equal(fullscreen.getAttribute("src"), inline.getAttribute("src")); + assert.match(overlayHolder.getAttribute("style")!, /invert\(1\) hue-rotate\(180deg\)/); + + // Intercept only the download anchor; never navigate or write a real file. + dom.document.createElement = ((...args: Parameters) => { + const element = originalCreateElement.apply(dom.document, args); + if (args[0] === "a") { + element.click = () => { clicked = { name: element.getAttribute("download")!, href: element.getAttribute("href")! }; }; + } + return element; + }) as typeof originalCreateElement; + await act(async () => { host.querySelector('[data-testid="visual-download"]')!.dispatchEvent(new dom.window.Event("click", { bubbles: true })); }); + assert.ok(downloaded); + assert.equal(await downloaded.text(), originalSvg); + assert.equal(downloaded.type, "image/svg+xml"); + assert.deepEqual(clicked, { name: "figure.svg", href: "blob:figure-test" }); + assert.equal(revoked, "blob:figure-test"); + } finally { + try { + await act(async () => root.unmount()); + } finally { + URL.createObjectURL = originalCreateUrl; + URL.revokeObjectURL = originalRevokeUrl; + dom.document.createElement = originalCreateElement; + host.remove(); + for (const [i, name] of names.entries()) { + if (originals[i]) Object.defineProperty(globalThis, name, originals[i]!); + else Reflect.deleteProperty(globalThis, name); + } + } + } +}); diff --git a/tests/tool-content-interaction.test.tsx b/tests/tool-content-interaction.test.tsx new file mode 100644 index 0000000..b23f8c6 --- /dev/null +++ b/tests/tool-content-interaction.test.tsx @@ -0,0 +1,78 @@ +import React, { act } from "react"; +(globalThis as { React?: typeof React }).React = React; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createRoot } from "react-dom/client"; +import { parseHTML } from "linkedom"; +import { QuestionDock } from "../components/question-dock.tsx"; +import { AnsweredCard } from "../components/answered-card.tsx"; +import { newAnswerResult, type QuestionAnswerData } from "../src/core/index.ts"; + +const dom = parseHTML(""); +Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }); + +test("actual select → submit → saved result → historical Why uses source data and presentation-only recovery", async () => { + const question = String.raw`Find $\\nabla f$ for $\begin{pmatrix}a&b\\c&d\end{pmatrix}$.`; + const label = String.raw`$\\nabla f$`; + const explanation = String.raw`First paragraph.\n\nThe matrix is $\begin{pmatrix}p&q\\r&s\end{pmatrix}$ and $\\nabla f$ is its gradient.`; + const input = { question, options: [{ label }, { label: "no" }], correctAnswer: label, explanation }; + const before = structuredClone(input); + let answer: QuestionAnswerData | undefined; + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); + try { + await act(async () => root.render( {}} onSubmit={a => { answer = a; }} />)); + assert.equal(host.querySelectorAll("mtr").length, 2, "the live matrix retains two rows"); + const choice = [...host.querySelectorAll('[role="radio"]')].find(button => button.querySelector(".katex"))!; + assert.ok(choice); + await act(async () => choice.click()); + assert.equal(choice.getAttribute("aria-checked"), "true"); + const submit = [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Submit answer"))!; + assert.equal(submit.disabled, false); + // linkedom does not implement the browser's default button→form + // action; dispatch its native submit event through React instead. + await act(async () => submit.closest("form")!.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }))); + assert.ok(answer); + assert.equal(answer.questionText, question); + assert.equal(answer.explanation, explanation); + assert.equal(answer.answers[0]!.value, label, "display recovery must not alter the submitted identity"); + assert.equal(answer.answers[0]!.label, label); + assert.equal(answer.correct, true); + const persisted = JSON.parse(JSON.stringify(newAnswerResult("interactive_source", "probe", answer))); + assert.deepEqual(persisted.details, JSON.parse(JSON.stringify(answer))); + assert.equal(persisted.details.explanation, explanation); + const saved = structuredClone(persisted.details); + await act(async () => root.render()); + const why = [...host.querySelectorAll("button")].find(button => button.textContent === "Why")!; + await act(async () => why.click()); + assert.equal(host.querySelectorAll("mtr").length, 4, "question and revealed explanation retain their rows"); + const paragraphs = host.querySelectorAll('[data-testid="tucked-explanation"] p'); + assert.equal(paragraphs.length, 2, "recovered paragraphs remain distinct in Why"); + assert.ok(paragraphs[0]!.parentElement!.classList.contains("[&>p:first-child]:inline")); + assert.ok(!paragraphs[0]!.parentElement!.classList.contains("[&_p]:inline")); + const annotations = [...host.querySelectorAll('annotation[encoding="application/x-tex"]')].map(n => n.textContent); + assert.equal(annotations.filter(t => t === String.raw`\nabla f`).length, 3); + assert.deepEqual(persisted.details, saved); + assert.deepEqual(input, before); + } finally { await act(async () => root.unmount()); host.remove(); } +}); + +test("committed-answer reveals keep the first paragraph inline without flattening later paragraphs", async () => { + const committed = String.raw`First paragraph.\n\nSecond paragraph with $\\nabla f$ and $\nu$.`; + const answer: QuestionAnswerData = { kind: "explain", questionText: "Explain the gradient.", answers: [], text: "Synthetic answer", committed, dontKnow: false, answeredAt: "2026-10-26T00:00:00.000Z" }; + const input = { question: answer.questionText, correctAnswer: committed }; + const before = structuredClone(answer); + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); + try { + await act(async () => root.render()); + const paragraphs = host.querySelectorAll("p"); + assert.equal(paragraphs.length, 3, "question plus two committed-answer paragraphs"); + assert.ok(paragraphs[1]!.parentElement!.classList.contains("[&>p:first-child]:inline")); + assert.ok(!paragraphs[1]!.parentElement!.classList.contains("[&_p]:inline")); + assert.equal(host.querySelectorAll(".katex-error").length, 0); + assert.deepEqual(answer, before); + } finally { await act(async () => root.unmount()); host.remove(); } +}); diff --git a/tests/tool-content-presentation.test.tsx b/tests/tool-content-presentation.test.tsx new file mode 100644 index 0000000..35c13d0 --- /dev/null +++ b/tests/tool-content-presentation.test.tsx @@ -0,0 +1,164 @@ +import React from "react"; +(globalThis as { React?: typeof React }).React = React; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { parseHTML } from "linkedom"; +import { MessageResponse, ToolContentResponse } from "../components/ai-elements/message.tsx"; +import { QuestionDock } from "../components/question-dock.tsx"; +import { AnsweredCard } from "../components/answered-card.tsx"; +import { answerForPresentation, buildAnswer, normalizeOptions, parseAskInput, parseExplainInput, parseQuizInput, + repairMathForDisplay, resolveCorrect, type QuestionAnswerData, type QuestionData } from "../src/core/index.ts"; + +const fixtures = JSON.parse(readFileSync(new URL("./fixtures/tool-rendering/matrix-regressions.json", import.meta.url), "utf8")) as { + cases: Array<{ chatId: string; source: string; raw: string; oldDisplay: string }>; +}; +const a = String.raw`$\begin{pmatrix}a&b\\c&d\end{pmatrix}$`; +const b = String.raw`$\begin{pmatrix}p&q\\r&s\end{pmatrix}$`; +const brokenA = a.replace(String.raw`\\c`, String.raw`\c`); +const brokenB = b.replace(String.raw`\\r`, String.raw`\r`); +const doubled = String.raw`$\\nabla f$`; +const input = { question: a, options: [{ label: a, value: "a" }, { label: b, value: "b" }], correctAnswer: "a", explanation: a }; +const dom = (html: string) => parseHTML(`${html}`).document; +const render = (text: string, recovered = true) => renderToStaticMarkup( + recovered ? {text} : {text}); +const tex = (html: string) => [...dom(html).querySelectorAll('annotation[encoding="application/x-tex"]')].map(n => n.textContent); + +for (const fixture of fixtures.cases) test(`preserves corpus matrix rows: ${fixture.chatId} ${fixture.source}`, () => { + assert.notEqual(fixture.raw, fixture.oldDisplay); + assert.equal(repairMathForDisplay(fixture.raw), fixture.raw); + const expected = dom(render(fixture.raw, false)); + const actual = dom(render(fixture.raw)); + assert.deepEqual(tex(actual.body.innerHTML), tex(expected.body.innerHTML)); + assert.equal(actual.querySelectorAll("mtr").length, expected.querySelectorAll("mtr").length); + assert.ok(actual.querySelectorAll("mtr").length > dom(render(fixture.oldDisplay, false)).querySelectorAll("mtr").length); + assert.equal(actual.querySelectorAll(".katex-error").length, 0); +}); + +test("generic nested environments, comments, and bare row-leading command names are protected", () => { + for (const environment of ["matrix", "pmatrix", "bmatrix", "Bmatrix", "vmatrix", "Vmatrix", "smallmatrix", "array", "aligned", "cases"]) { + const source = `$\\begin{${environment}}a&b\\\\beta&d\\end{${environment}}$`; + assert.equal(repairMathForDisplay(source), source); + } + const nested = "$$\n" + String.raw`\begin{aligned}x&=\begin{pmatrix}a&b\\c&d\end{pmatrix}\\% \end{aligned} is a comment` + "\n" + String.raw`r&=s\end{aligned}` + "\n$$"; + assert.equal(repairMathForDisplay(nested), nested); + const spaced = String.raw`$\begin {pmatrix}a&b\\c&d\end {pmatrix}$`; + assert.equal(repairMathForDisplay(spaced), spaced); + const mixed = String.raw`$\\lambda+\begin{pmatrix}a&b\\c&d\end{pmatrix}$`; + assert.equal(repairMathForDisplay(mixed), mixed.replace(String.raw`\\lambda`, String.raw`\lambda`)); +}); + +test("corpus document framing and final-display percentages recover without new errors", () => { + const cases = JSON.parse(readFileSync(new URL("./fixtures/tool-rendering/document-percent-regressions.json", import.meta.url), "utf8")); + for (const fixture of cases) { + const recovered = repairMathForDisplay(fixture.raw); + const document = dom(render(fixture.raw)); + assert.equal(document.querySelectorAll(".katex-error").length, 0, fixture.chatId); + assert.equal(repairMathForDisplay(recovered), recovered, fixture.chatId); + if (fixture.kind === "percent-at-display-end") { + assert.ok([...document.querySelectorAll(".katex-html")].some(node => node.textContent?.includes("%")), fixture.chatId); + assert.ok(!recovered.includes("\\\\%"), fixture.chatId); + } else { + assert.match(recovered, /\n\$\$\n/); + assert.match(recovered, /\n\$\$$/); + } + } +}); +test("mixed-escaped environment markers retain existing recovery rather than treating an opener as native", () => { + const mixedFixtures = JSON.parse(readFileSync(new URL("./fixtures/tool-rendering/mixed-escaping-regressions.json", import.meta.url), "utf8")) as typeof fixtures; + assert.equal(mixedFixtures.cases.length, 2); + for (const fixture of mixedFixtures.cases) { + const recovered = repairMathForDisplay(fixture.raw); + assert.equal(repairMathForDisplay(recovered), recovered, fixture.chatId); + assert.doesNotMatch(recovered, /\\\\end\{/); + assert.equal(dom(render(fixture.raw)).querySelectorAll(".katex-error").length, 0); + } +}); + +test("presentation-equivalent labels cannot make a source-invalid correctAnswer gradeable", () => { + const source = { question: "Which?", options: [{ label: String.raw`$\nabla f$` }, { label: "no" }], + correctAnswer: doubled, explanation: "A model-authored identity mismatch" }; + const before = structuredClone(source); + assert.equal(repairMathForDisplay(doubled), source.options[0]!.label); + assert.equal(parseQuizInput(source), undefined); + assert.deepEqual(source, before); +}); + +test("shape parsers retain source text and identities; recovery is presentation only", () => { + const quiz = { ...input, question: doubled, explanation: "$\forall x$", options: [{ label: doubled }, { label: "no" }], correctAnswer: doubled }; + const ask = { question: doubled, options: [{ label: doubled, value: doubled, description: doubled }, { label: "no" }] }; + const explain = { question: doubled, correctAnswer: "$\forall x$" }; + const before = structuredClone({ quiz, ask, explain }); + assert.equal(parseQuizInput(quiz)!.question, doubled); + assert.equal(parseQuizInput(quiz)!.explanation, quiz.explanation); + assert.equal(parseQuizInput(quiz)!.correctAnswer, doubled); + assert.deepEqual(parseAskInput(ask), ask); + assert.deepEqual(parseExplainInput(explain), explain); + assert.deepEqual(tex(render(doubled)), [String.raw`\nabla f`]); + assert.deepEqual(tex(render(doubled, false)), [String.raw`\\nabla f`]); + assert.deepEqual({ quiz, ask, explain }, before); +}); + +test("the live dock recovers only its rendering, including question, context and option labels", () => { + const source = { question: doubled, details: doubled, options: [{ label: doubled }, { label: "no" }] }; + const before = structuredClone(source); + const html = renderToStaticMarkup( {}} onSubmit={() => {}} />); + assert.deepEqual(tex(html), Array(3).fill(String.raw`\nabla f`)); + assert.deepEqual(source, before); +}); + +test("answers snapshot source text, not recovered labels or identities", () => { + const source = { ...input, question: doubled, explanation: doubled, options: [{ label: doubled }, { label: "no" }], correctAnswer: doubled }; + const parsed = parseQuizInput(source)!; + const options = normalizeOptions(parsed.options); + const qd: QuestionData = { id: "source", kind: "quiz", question: parsed.question, mode: "single-select", options, + correctIndices: resolveCorrect(parsed.correctAnswer, options).indices, explanation: parsed.explanation, createdAt: "synthetic" }; + const answer = buildAnswer(qd, { values: [doubled], confidence: 70 }, "probe"); + assert.equal(answer.questionText, doubled); + assert.equal(answer.options![0]!.label, doubled); + assert.equal(answer.answers[0]!.value, doubled); + assert.equal(answer.answers[0]!.label, doubled); + assert.equal(answer.explanation, doubled); + assert.equal(answer.correct, true); + const before = structuredClone(answer); + assert.deepEqual(tex(renderToStaticMarkup()), Array(2).fill(String.raw`\nabla f`)); + assert.deepEqual(answer, before); +}); + +function historical(): QuestionAnswerData { + return { kind: "quiz", questionText: brokenA, options: [{ index: 1, label: brokenB }, { index: 2, label: brokenA }], + answers: [{ index: 1, label: brokenB, value: "b" }], correct: false, correctIndices: [2], dontKnow: false, + explanation: brokenA, confidence: 70, answeredAt: "synthetic" }; +} +test("historical source join restores matrices while retaining recorded order, answer and grade", () => { + const answer = historical(); + const before = structuredClone({ answer, input }); + const projection = answerForPresentation(answer, input); + assert.equal(projection.questionText, a); + assert.equal(projection.explanation, a); + assert.deepEqual(projection.options, [{ index: 1, label: b }, { index: 2, label: a }]); + assert.equal(projection.answers, answer.answers); + assert.equal(projection.correctIndices, answer.correctIndices); + assert.equal(projection.correct, false); + assert.equal(projection.confidence, 70); + const html = renderToStaticMarkup(); + assert.deepEqual(tex(html), [a.slice(1,-1), b.slice(1,-1), a.slice(1,-1)]); + assert.match(html, /incorrect/); + assert.deepEqual({ answer, input }, before); +}); + +test("historical ambiguous options and absent/invalid original calls retain their snapshots", () => { + const source = { ...input, options: [...input.options, { label: String.raw`$\begin{matrix}x\\y\end{matrix}$`, value: "c" }] }; + const answer = { ...historical(), answers: [], dontKnow: true, options: [ + { index: 1, label: brokenB }, { index: 2, label: brokenA }, { index: 3, label: String.raw`$\begin{matrix}x\y\end{matrix}$` }, + ] }; + assert.deepEqual(answerForPresentation(answer, source).options, answer.options); + assert.equal(answerForPresentation(answer, undefined), answer); + assert.equal(answerForPresentation(answer, { question: a }), answer); + const dismissed = { kind: "quiz", dismissed: true } as QuestionAnswerData; + assert.equal(answerForPresentation(dismissed, input), dismissed); + const before = structuredClone(answer); + renderToStaticMarkup(); + assert.deepEqual(answer, before); +}); diff --git a/tests/tool-parts.test.ts b/tests/tool-parts.test.ts index baccdfe..a1576f5 100644 --- a/tests/tool-parts.test.ts +++ b/tests/tool-parts.test.ts @@ -12,7 +12,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { quizInputProblems } from "../src/core/question-core.ts"; import { Value } from "typebox/value"; -import { restoreMathEscapes } from "../src/core/math-delims.ts"; +import { repairMathForDisplay, restoreMathEscapes } from "../src/core/math-delims.ts"; import { parseAskInput, parseProbeDoneInput, @@ -170,7 +170,7 @@ describe("LaTeX-escape restoration (the JSON-debris fix, owner ruling 2026-09-26 // The exact live shapes from the modal-logic session: single-backslash // LaTeX inside JSON args — JSON escape resolution ate the command's // first letter (\f → FF ate 'f' of \forall, \v → VT ate 'v' of \varphi). - it("parseQuizInput restores escape debris inside math spans (question + options + explanation)", () => { + it("presentation restores debris from parsed source fields (question + options + explanation)", () => { const parsed = parseQuizInput({ question: "What does \\(\\forall x\\, \\varphi(x)\\) mean?", options: [ @@ -182,18 +182,18 @@ describe("LaTeX-escape restoration (the JSON-debris fix, owner ruling 2026-09-26 }); assert.ok(parsed); assert.equal(parsed.question, "What does \\(\\forall x\\, \\varphi(x)\\) mean?"); - assert.equal(parsed.options[0]!.label, "\\(\\varphi\\) is true when every element satisfies it"); - assert.equal(parsed.options[1]!.label, "\\(\\forall\\) and \\(\\exists\\) are just shorthand"); - assert.equal(parsed.explanation, "\\(\\forall\\) quantifies over the domain."); + assert.equal(repairMathForDisplay(parsed.options[0]!.label), "\\(\\varphi\\) is true when every element satisfies it"); + assert.equal(repairMathForDisplay(parsed.options[1]!.label), "\\(\\forall\\) and \\(\\exists\\) are just shorthand"); + assert.equal(repairMathForDisplay(parsed.explanation), "\\(\\forall\\) quantifies over the domain."); }); - it("parseAskInput restores debris in ask option labels too", () => { + it("presentation restores debris from parsed ask option labels too", () => { const parsed = parseAskInput({ question: "Goal?", options: [{ label: "\\(\x0corall\\) rigor" }, { label: "Intuition first" }], }); assert.ok(parsed); - assert.equal(parsed.options![0]!.label, "\\(\\forall\\) rigor"); + assert.equal(repairMathForDisplay(parsed.options![0]!.label), "\\(\\forall\\) rigor"); }); it("restores TAB debris (the \\to case) inside spans only", () => { @@ -204,7 +204,7 @@ describe("LaTeX-escape restoration (the JSON-debris fix, owner ruling 2026-09-26 explanation: "", }); assert.ok(parsed); - assert.equal(parsed.question, "Is \\(p\\to q\\) valid?"); + assert.equal(repairMathForDisplay(parsed.question), "Is \\(p\\to q\\) valid?"); }); it("control chars OUTSIDE math spans are left alone (real prose/pasted text)", () => { @@ -253,7 +253,7 @@ describe("display-math newline rule (Option A, owner ruling 2026-09-27)", () => explanation: "", }); assert.ok(parsed); - assert.equal(parsed.question, "Evaluate \\[\\left( a \\right)\\] carefully."); + assert.equal(repairMathForDisplay(parsed.question), "Evaluate \\[\\left( a \\right)\\] carefully."); }); it("sloppy \\neq in display math stays broken — the accepted cost (newline kept, byte-identical to a real line break)", () => { @@ -276,7 +276,7 @@ describe("display-math newline rule (Option A, owner ruling 2026-09-27)", () => explanation: "", }); assert.ok(parsed); - assert.equal(parsed.question, "Is \\(p \\neq q\\) valid?"); + assert.equal(repairMathForDisplay(parsed.question), "Is \\(p \\neq q\\) valid?"); }); }); @@ -289,9 +289,9 @@ describe("dollar-delimiter recognition — existing escape restoration rules", ( explanation: "Here $p\to q$ is an implication.", }); assert.ok(parsed); - assert.equal(parsed.question, "What does $\\forall x$ mean?"); - assert.equal(parsed.options[0]!.label, "$\\varphi$ holds"); - assert.equal(parsed.explanation, "Here $p\\to q$ is an implication."); + assert.equal(repairMathForDisplay(parsed.question), "What does $\\forall x$ mean?"); + assert.equal(repairMathForDisplay(parsed.options[0]!.label), "$\\varphi$ holds"); + assert.equal(repairMathForDisplay(parsed.explanation), "Here $p\\to q$ is an implication."); }); it("keeps the same six-character inline recovery map", () => { @@ -320,21 +320,21 @@ describe("dollar-delimiter recognition — existing escape restoration rules", ( } }); - it("preserves legacy replacement frequency when a span has multiple debris characters", () => { + it("restores all debris in one pass consistently across delimiter syntaxes", () => { const body = "p\to q, \rho"; const legacy = restoreMathEscapes(`\\(${body}\\)`).slice(2, -2); assert.equal(restoreMathEscapes(`$${body}$`), `$${legacy}$`); - assert.equal(legacy, "p\\to q, \rho", "the existing first-match-only behavior is unchanged"); + assert.equal(legacy, "p\\to q, \\rho", "recovery converges instead of requiring a second render"); }); it("shares dollar recovery with ask and free-recall fields", () => { const ask = parseAskInput({ question: "Goal?", options: [{ label: "$\x0corall$ rigor" }, { label: "Intuition" }] }); assert.ok(ask); - assert.equal(ask.options![0]!.label, "$\\forall$ rigor"); + assert.equal(repairMathForDisplay(ask.options![0]!.label), "$\\forall$ rigor"); const explain = parseExplainInput({ question: "Explain $p\to q$", correctAnswer: "$p\neq q$ is different." }); assert.ok(explain); - assert.equal(explain.question, "Explain $p\\to q$"); - assert.equal(explain.correctAnswer, "$p\\neq q$ is different."); + assert.equal(repairMathForDisplay(explain.question), "Explain $p\\to q$"); + assert.equal(repairMathForDisplay(explain.correctAnswer), "$p\\neq q$ is different."); }); it("leaves non-math debris, escaped opening dollars, and code examples untouched", () => { @@ -401,13 +401,9 @@ describe("parseExplainInput: the dock's display seam for explain calls", () => { }); }); -describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)", () => { - // xiaomi/mimo-v2.6-pro and deepseek-v4.1-flash stochastically DOUBLE - // LaTeX command backslashes inside JSON tool arguments — per-call and - // per-field (gradient-descent msg 3: question doubled, explanation and - // options in the SAME call clean). restoreMathEscapesDeep now rides - // healOverescapedCommands after the debris restore, so doubled spans - // heal at every display parse: live dock, settled cards, old records. +describe("over-escaped command recovery after shape parsing", () => { + // Keep these real authoring shapes, but recover only for presentation. + // Parsed source is also used to build durable answers and identities. const CLEAN = "Let $f : \\mathbb{R}^n \\to \\mathbb{R}$ be differentiable at $x$, with $\\nabla f(x) \\neq 0$. Among all unit directions $\\|u\\|_2 = 1$, which makes $D_u f(x)$ most negative?"; const DOUBLE = (s: string): string => s.replace(/\\/g, "\\\\"); @@ -420,19 +416,21 @@ describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)" explanation: "By Cauchy\u2013Schwarz: $$u^\\star = -\\frac{\\nabla f(x)}{\\|\\nabla f(x)\\|_2}$$", }; - it("parseQuizInput heals a doubled question field (the gradient-descent msg-3 shape)", () => { + it("presentation heals a doubled question field (the gradient-descent msg-3 shape)", () => { const parsed = parseQuizInput({ ...BASE, question: DOUBLE(CLEAN) }); assert.ok(parsed, "parses"); - assert.equal(parsed.question, CLEAN); + assert.equal(parsed.question, DOUBLE(CLEAN), "shape parsing retains source"); + assert.equal(repairMathForDisplay(parsed.question), CLEAN); // The CLEAN sibling fields of the same call stay byte-identical. assert.equal(parsed.options[0]!.label, BASE.options[0]!.label); assert.equal(parsed.explanation, BASE.explanation); }); - it("parseQuizInput heals a quadrupled field (the agent's over-correction, msg 6)", () => { + it("presentation heals a quadrupled field (the agent's over-correction, msg 6)", () => { const parsed = parseQuizInput({ ...BASE, question: DOUBLE(DOUBLE(CLEAN)) }); assert.ok(parsed, "parses"); - assert.equal(parsed.question, CLEAN); + assert.equal(parsed.question, DOUBLE(DOUBLE(CLEAN)), "shape parsing retains source"); + assert.equal(repairMathForDisplay(parsed.question), CLEAN); }); it("doubled option labels and explanations heal too (the inference-runtime msg-7 shape)", () => { @@ -448,10 +446,10 @@ describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)" assert.ok(parsed, "parses"); assert.equal(parsed.options[0]!.label, "$mkn$"); assert.equal(parsed.options[1]!.label, "$2mkn$"); - assert.equal(parsed.explanation, "$$c_{ij} = \\sum_{\\ell=1}^{k} a_{i\\ell}\\, b_{\\ell j}$$"); + assert.equal(repairMathForDisplay(parsed.explanation), "$$c_{ij} = \\sum_{\\ell=1}^{k} a_{i\\ell}\\, b_{\\ell j}$$"); }); - it("parseAskInput heals doubled ask-option descriptions the same way", () => { + it("presentation heals doubled ask-option descriptions the same way", () => { const parsed = parseAskInput({ question: "Depth?", options: [ @@ -460,7 +458,7 @@ describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)" ], }); assert.ok(parsed, "parses"); - assert.equal(parsed.options![0]!.description, "why $-\\nabla f$ is steepest descent"); + assert.equal(repairMathForDisplay(parsed.options![0]!.description!), "why $-\\nabla f$ is steepest descent"); }); it("clean records pass through byte-identical (no over-heal)", () => { diff --git a/tests/tooltip-mechanism.test.tsx b/tests/tooltip-mechanism.test.tsx index 653cfe6..b83d834 100644 --- a/tests/tooltip-mechanism.test.tsx +++ b/tests/tooltip-mechanism.test.tsx @@ -17,7 +17,7 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { Hint, hintText } from "../components/ui/hint.tsx"; import { TOOLTIP_DELAY_MS } from "../components/ui/tooltip.tsx"; -import { AUTO_UPDATE_HINT } from "../components/settings/settings-dialog.tsx"; +import { AUTO_UPDATE_HINT } from "../components/settings/update-settings.tsx"; import { IS_MAC } from "../src/core/keyboard.ts"; describe("hintText — the parenthesized composition", () => { @@ -46,7 +46,7 @@ describe("subtlety round pins (owner 2026-09-25)", () => { describe("spot-pins: the dev-speak cull (owner copy review at merge)", () => { it("the auto-update hint is honest user language — no stream jargon", () => { - assert.equal(AUTO_UPDATE_HINT, "Automatic updates arrive in an upcoming release"); + assert.equal(AUTO_UPDATE_HINT, "Check at startup and every 24 hours. Turning this off does not stop updates already in progress or ready to install."); assert.ok(!AUTO_UPDATE_HINT.includes("wave"), "no stream vocabulary"); }); }); diff --git a/tests/turn-liveness.test.ts b/tests/turn-liveness.test.ts index 91f1a13..e9bbc03 100644 --- a/tests/turn-liveness.test.ts +++ b/tests/turn-liveness.test.ts @@ -11,8 +11,19 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { beginTurn, endTurn, isTurnRunning } from "../src/server/turn-liveness.ts"; import { subscribe, type ServerEvent } from "../src/core/events.ts"; +import { rememberTurnFailure, settledTurnResponse } from "../src/server/turn-runs.ts"; describe("turn liveness registry", () => { + it("retains a late-attach failure notice until the next accepted begin", async () => { + const id = "chat_failure_notice_unit"; + rememberTurnFailure(id, "The reply could not be saved."); + assert.match(await settledTurnResponse(id).text(), /The reply could not be saved\./); + assert.match(await settledTurnResponse(id).text(), /The reply could not be saved\./, "reading does not consume the notice"); + assert.equal(beginTurn(id), true); + assert.doesNotMatch(await settledTurnResponse(id).text(), /"type":"error"/); + endTurn(id); + }); + it("begin/end round trip; a second begin is rejected until the turn ends", () => { const id = "chat_liveness_unit"; assert.equal(isTurnRunning(id), false, "initially idle"); diff --git a/tests/turn-recovery.test.ts b/tests/turn-recovery.test.ts new file mode 100644 index 0000000..2ef7157 --- /dev/null +++ b/tests/turn-recovery.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Agent, AgentMessage } from "@earendil-works/pi-agent-core"; +import type { ChatStore } from "../src/core/ports.ts"; +import { runPiTurn } from "../src/server/pi/ui-stream.ts"; +import { persistTurn } from "../src/server/turn-service.ts"; + +/** No engine or network: exercise the runner's awaited listener seam. */ +function agentEndingWith(loopFailure?: Error): Agent { + const listeners: Array<(event: never) => unknown> = []; + return { + state: { messages: [] }, + subscribe(listener: (event: never) => unknown) { listeners.push(listener); return () => {}; }, + async continue() { + await Promise.resolve(); // let the view subscribe before the terminal + if (loopFailure) throw loopFailure; + for (const listener of listeners) await listener({ type: "agent_end", messages: [] } as never); + }, + abort() {}, + } as unknown as Agent; +} + +function wireFrames(wire: string): string[] { + return wire.split("\n\n").filter(Boolean).map(frame => frame.slice("data: ".length)); +} + +describe("turn settlement recovery", () => { + it("reports a failed canonical save before DONE and cleans up before reporting", async () => { + const order: string[] = []; + const handle = runPiTurn({ + agent: agentEndingWith(), + onFinish() { order.push("save"); throw new Error("synthetic disk failure"); }, + onSettleFailure() { order.push("cleanup"); }, + onSettleError() { order.push("report"); return "The reply could not be saved."; }, + }); + const frames = wireFrames(await new Response(handle.subscribe()).text()); + assert.deepEqual(order, ["save", "cleanup", "report"]); + assert.equal(frames.at(-1), "[DONE]"); + assert.equal(frames.filter(f => f.includes('"type":"error"')).length, 1); + assert.ok(frames.at(-2)?.includes("The reply could not be saved.")); + }); + + it("still reports settlement failure when cleanup itself throws", async () => { + const handle = runPiTurn({ + agent: agentEndingWith(), + onFinish() { throw new Error("synthetic save failure"); }, + onSettleFailure() { throw new Error("synthetic cleanup failure"); }, + onSettleError: () => "The reply could not be saved.", + }); + const frames = wireFrames(await new Response(handle.subscribe()).text()); + assert.equal(frames.at(-1), "[DONE]"); + assert.equal(frames.filter(f => f.includes('"type":"error"')).length, 1); + }); + + it("does not replace an already reported loop failure with a second settlement error", async () => { + let cleanups = 0; + const handle = runPiTurn({ + agent: agentEndingWith(new Error("synthetic loop failure")), + onError: () => "An error occurred.", + onFinish() { throw new Error("synthetic save failure"); }, + onSettleFailure(error, reportedErrorText) { + cleanups++; + assert.match(String(error), /synthetic save failure/); + assert.equal(reportedErrorText, "An error occurred."); + }, + }); + const frames = wireFrames(await new Response(handle.subscribe()).text()); + assert.equal(cleanups, 1); + assert.equal(frames.filter(f => f.includes('"type":"error"')).length, 1); + assert.equal(frames.at(-1), "[DONE]"); + }); + + it("a healthy terminal follows persistence and emits no error", async () => { + let saves = 0; + const handle = runPiTurn({ agent: agentEndingWith(), onFinish() { saves++; } }); + const frames = wireFrames(await new Response(handle.subscribe()).text()); + assert.equal(saves, 1); + assert.equal(frames.at(-1), "[DONE]"); + assert.equal(frames.some(f => f.includes('"type":"error"')), false); + }); + + it("does not contain a canonical write failure as an empty successful persist", () => { + const store = { + getChat: () => ({ messages: [] }), + saveMessages() { throw new Error("synthetic disk failure"); }, + } as unknown as ChatStore; + const message = { role: "assistant", content: [{ type: "text", text: "Synthetic reply" }], stopReason: "stop", timestamp: 1 } as AgentMessage; + assert.throws(() => persistTurn(store, "fixture", 0, [message], false), /The reply could not be saved\./); + }); +}); diff --git a/tests/web-subscription-client.test.tsx b/tests/web-subscription-client.test.tsx new file mode 100644 index 0000000..df5e2a8 --- /dev/null +++ b/tests/web-subscription-client.test.tsx @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import test, { afterEach } from "node:test"; +import { parseHTML } from "linkedom"; +import { createNotifyHub } from "../components/server-events.tsx"; +import { createWebChatGPTBridge, createWebNativeBridge } from "../components/settings/subscription-client.ts"; +import { createRequire } from "node:module"; + +const dom = parseHTML(""); +Object.assign(globalThis, { window: dom.window, document: dom.document }); +const originalFetch = globalThis.fetch; +const action = "/api/subscriptions/browser?id=00000000-0000-4000-8000-000000000001"; +afterEach(() => { globalThis.fetch = originalFetch; delete window.lh; }); + +test("web ChatGPT reserves a new tab before async login, navigates only to an opaque action and receives push/resync", async () => { + const calls: string[] = []; + const tab = { opener: {}, closed: false, location: { href: "about:blank" }, close() { this.closed = true; } }; + window.open = (() => { calls.push("tab"); return tab; }) as unknown as typeof window.open; + const safe = { available: true, pending: true, allowBackgroundUse: true, activeId: null, accounts: [], error: null }; + globalThis.fetch = async (url, options) => { + assert.equal(url, "/api/subscriptions"); assert.equal(new Headers(options?.headers).get("x-learnharness-subscriptions"), "1"); + if (!options?.method) return Response.json({ chatgpt: safe, native: [] }); + calls.push("login"); assert.equal(tab.opener, null); + assert.deepEqual(JSON.parse(String(options.body)), { providerId: "chatgpt", op: "login" }); + return Response.json({ value: safe, browserAction: action }); + }; + const hub = createNotifyHub(), bridge = createWebChatGPTBridge(hub); + assert.deepEqual(await bridge.login(), safe); assert.deepEqual(calls, ["tab", "login"]); + assert.equal(tab.location.href, action); assert(!tab.closed); + let updates = 0; const off = bridge.onChange(() => updates++); + hub.dispatch("subscriptions-changed", "{}"); hub.fireOpen(); assert.equal(updates, 2); + off(); hub.fireOpen(); assert.equal(updates, 2); + assert.deepEqual(await bridge.status(), safe); +}); + +test("blocked popups do not start OAuth; failed login closes the reserved tab", async () => { + const bridge = createWebChatGPTBridge(null); + window.open = () => null; + globalThis.fetch = async () => assert.fail("blocked popup must not start sign-in"); + await assert.rejects(bridge.login(), /Allow a sign-in tab/); + const tab = { opener: {}, closed: false, close() { this.closed = true; } }; + window.open = (() => tab) as unknown as typeof window.open; + globalThis.fetch = async () => new Response(null, { status: 400 }); + await assert.rejects(bridge.login()); assert(tab.closed); +}); + +test("native web login retains device prompts; only the explicit Open browser action opens a tab", async () => { + const requests: Record[] = []; let tabs = 0; + const tab = { opener: {}, closed: false, location: { href: "" }, close() {} }; + window.open = (() => { tabs++; return tab; }) as unknown as typeof window.open; + globalThis.fetch = async (_url, options) => { + const command = JSON.parse(String(options?.body)); requests.push(command); + return Response.json(command.op === "open" ? { browserAction: action } : {}); + }; + const bridge = createWebNativeBridge(null); + await bridge.login("github-copilot", false); assert.equal(tabs, 0); + await bridge.respond("github-copilot", "attempt", "prompt", ""); + await bridge.open("github-copilot", "attempt", "action"); assert.equal(tabs, 1); assert.equal(tab.location.href, action); + assert.deepEqual(requests, [{ providerId: "github-copilot", op: "login", enabled: false }, { providerId: "github-copilot", op: "respond", attemptId: "attempt", promptId: "prompt", value: "" }, { providerId: "github-copilot", op: "open", attemptId: "attempt", actionId: "action" }]); +}); + +test("Electron dev uses its narrow external-browser bridge, without attempting a renderer popup", async () => { + const opened: string[] = []; + window.lh = { expandWindow() {}, subscriptionTransport: "web", openSubscriptionBrowser: async (target) => { opened.push(target); } }; + window.open = () => assert.fail("Electron denies renderer popups"); + globalThis.fetch = async () => Response.json({ value: { pending: true }, browserAction: action }); + await createWebChatGPTBridge(null).login(); assert.deepEqual(opened, [action]); +}); + +test("web notices identify the server machine and same-machine ChatGPT sign-in; desktop copy stays unchanged", async () => { + const { CHATGPT_NOTICE, chatGPTNotice } = await import("../components/settings/chatgpt-connection.tsx"); + const { subscriptionNotice, subscriptionDisconnectCopy } = await import("../components/settings/subscription-connections.tsx"); + assert.equal(chatGPTNotice(), CHATGPT_NOTICE); assert(CHATGPT_NOTICE.includes("on this device")); + assert(chatGPTNotice(true).includes("on the machine running LearnHarness")); + assert(chatGPTNotice(true).endsWith("Complete ChatGPT sign-in in a browser on that same machine.")); + for (const label of ["GitHub Copilot", "Grok"]) { + assert(subscriptionNotice(label).includes("stored locally")); assert(subscriptionDisconnectCopy(label).includes("from this device")); + assert(subscriptionNotice(label, true).includes("on the machine running LearnHarness")); + assert(subscriptionDisconnectCopy(label, true).includes("from the machine running LearnHarness")); + } +}); + +const require = createRequire(import.meta.url); +const { openWebSubscriptionBrowser } = require("../electron/web-subscription-browser.cjs"); +test("desktop-dev opener accepts only local action IDs, does not follow redirects or use browser cookies", async () => { + const opened: string[] = []; + const options = { + openExternal: async (url: string) => { opened.push(url); }, + fetch: async (url: URL, init: RequestInit) => { + assert.equal(url.origin, "http://localhost:3000"); assert.equal(url.pathname, "/api/subscriptions/browser"); + assert.equal(init.method, "POST"); assert.equal(init.redirect, "manual"); assert.equal(init.credentials, "omit"); + assert.equal(new Headers(init.headers).get("origin"), "http://localhost:3000"); + return new Response(null, { status: 303, headers: { Location: "https://auth.openai.com/authorize?state=fixture" } }); + }, + }; + await openWebSubscriptionBrowser(action, "http://localhost:3000", options); assert.equal(opened.length, 1); + for (const target of ["https://unrelated.invalid", "//unrelated.invalid", "/api/keys", `${action}&target=evil`]) await assert.rejects(openWebSubscriptionBrowser(target, "http://localhost:3000", options), /Invalid sign-in action/); + for (const target of ["file:///private/fixture", "http://unrelated.invalid", "https://user:pass@unrelated.invalid"]) await assert.rejects(openWebSubscriptionBrowser(action, "http://localhost:3000", { ...options, fetch: async () => new Response(null, { status: 303, headers: { Location: target } }) })); + assert.equal(opened.length, 1); +}); diff --git a/tests/web-subscription-payload.test.ts b/tests/web-subscription-payload.test.ts new file mode 100644 index 0000000..d860660 --- /dev/null +++ b/tests/web-subscription-payload.test.ts @@ -0,0 +1,21 @@ +/** Exercise the real curator against synthetic payloads, never the repo payload. */ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, copyFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +for (const name of ["chatgpt-credentials.json", "github-copilot-credentials.json", "xai-credentials.json"]) { + for (const secret of [name, `${name}.fixture.tmp`]) test(`payload curator refuses web credentials: ${secret}`, () => { + const root = mkdtempSync(path.join(tmpdir(), "lh-sub-payload-")); + try { + mkdirSync(path.join(root, "scripts")); mkdirSync(path.join(root, ".next", "standalone"), { recursive: true }); mkdirSync(path.join(root, ".next", "static")); + copyFileSync(new URL("../scripts/build-server.mjs", import.meta.url), path.join(root, "scripts", "build-server.mjs")); + writeFileSync(path.join(root, ".next", "standalone", "server.js"), "// fixture"); + writeFileSync(path.join(root, ".next", "standalone", secret), "fixture-not-real-credentials", { mode: 0o600 }); + const result = spawnSync(process.execPath, [path.join(root, "scripts", "build-server.mjs")], { encoding: "utf8", env: process.env }); + assert.equal(result.status, 1); assert(result.stderr.includes(`REFUSING: standalone contains ${secret}`)); + } finally { rmSync(root, { recursive: true, force: true }); } + }); +} diff --git a/tests/web-subscriptions.test.ts b/tests/web-subscriptions.test.ts new file mode 100644 index 0000000..f34141f --- /dev/null +++ b/tests/web-subscriptions.test.ts @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import test, { after } from "node:test"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { createRequire } from "node:module"; +import { createModels, type OAuthCredential, type ProviderAuthInteraction } from "@earendil-works/pi-ai"; +import { githubCopilotProvider } from "@earendil-works/pi-ai/providers/github-copilot"; +import { xaiProvider } from "@earendil-works/pi-ai/providers/xai"; + +const directory = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-sub-")); +process.env.LH_AUTH_FILE = path.join(directory, "auth.json"); +process.env.LH_DATA_DIR = path.join(directory, "data"); +delete process.env.LH_DESKTOP_SERVER; delete process.env.LH_DESKTOP_API_SECRET; +await fs.writeFile(process.env.LH_AUTH_FILE, "{}", { mode: 0o600 }); +const require = createRequire(import.meta.url); +const { SCOPES } = require("../electron/chatgpt.cjs"); +const { createWebSubscriptionOwner, webSubscriptionOwner, webSubscriptionsEnabled } = await import("../src/server/web-subscriptions.ts"); +const { subscriptionControlsResponse, subscriptionBrowserResponse } = await import("../src/server/web-subscription-http.ts"); +const chat = await import("../src/agent/chatgpt-bridge.ts"); +const native = await import("../src/agent/subscription-bridge.ts"); +const { piModels } = await import("../src/agent/models.ts"); +const { refreshConnectionCatalog } = await import("../src/server/model-catalog.ts"); +const model = piModels().getModels("openai").find((entry) => entry.api === "openai-responses")!; +const originalFetch = globalThis.fetch; +globalThis.fetch = async (input) => { + assert.equal(String(input), "https://api.openai.com/v1/models", "unexpected network request forbidden"); + return Response.json({ models: [{ slug: model.id, display_name: "Fixture ChatGPT", visibility: "list" }] }); +}; +const copilot = githubCopilotProvider().getModels()[0]!; +const credentials = (id: string): OAuthCredential => ({ 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] } : {}) }); +await fs.writeFile(path.join(directory, "chatgpt-credentials.json"), JSON.stringify({ version: 1, hostId: "fixture-host", allowBackgroundUse: true, activeId: "fixture-account", registrations: [{ id: "fixture-account", clientId: "oaiapp_fixture", issuer: "https://auth.openai.com", subject: "fixture-subject", email: "fixture@example.invalid", tokens: { accessToken: "fixture-chatgpt-access", refreshToken: "fixture-chatgpt-refresh", idToken: "fixture-id-token", scopes: SCOPES, expiresAt: Date.now() + 3600000 } }] }), { mode: 0o600 }); +for (const id of ["github-copilot", "xai"]) await fs.writeFile(path.join(directory, `${id}-credentials.json`), JSON.stringify({ version: 1, allowBackgroundUse: true, connectionId: "00000000-0000-4000-8000-000000000001", credential: credentials(id) }), { mode: 0o600 }); +function request(command?: Record, origin = "http://192.168.1.10:3000") { + return new Request(`${origin}/api/subscriptions`, { method: command ? "POST" : "GET", headers: { host: new URL(origin).host, origin, "sec-fetch-site": "same-origin", "x-learnharness-subscriptions": "1", "content-type": "application/json" }, ...(command ? { body: JSON.stringify(command) } : {}) }); +} +after(async () => { webSubscriptionOwner().close(); globalThis.fetch = originalFetch; await fs.rm(directory, { recursive: true, force: true }); }); + +test("ordinary web restores configured sibling credentials, private runtime leases work for LAN-owned requests", async () => { + await refreshConnectionCatalog(); + assert(chat.chatGPTConnected()); assert(native.nativeSubscriptionConnected("github-copilot")); assert(native.nativeSubscriptionConnected("xai")); + assert.equal((await chat.chatGPTLease(chat.chatGPTScope(model.id), false)).accessToken, "fixture-chatgpt-access"); + const xaiModel = native.nativeSubscriptionModels().find((entry) => entry.provider === "xai-subscription")!; + assert.equal((await native.subscriptionLease(native.subscriptionScope("xai", xaiModel.id), false)).apiKey, "fixture-xai-access"); + const response = await subscriptionControlsResponse(request()); + assert.equal(response.status, 200); assert.equal(response.headers.get("cache-control"), "no-store"); + const publicState = await response.text(); + for (const secret of ["fixture-chatgpt-access", "fixture-chatgpt-refresh", "fixture-id-token", "fixture-xai-access", "fixture-refresh"]) assert(!publicState.includes(secret)); + assert(publicState.includes("fixture@example.invalid")); +}); + +test("web background OFF immediately aborts background streams, retains foreground and rejects private background leases", async () => { + const foreground = chat.chatGPTAbortController(false), background = chat.chatGPTAbortController(true); + const nativeForeground = native.subscriptionAbortController("xai", false), nativeBackground = native.subscriptionAbortController("xai", true); + try { + assert.equal((await subscriptionControlsResponse(request({ providerId: "chatgpt", op: "consent", enabled: false }))).status, 200); + assert(background.controller.signal.aborted); assert(!foreground.controller.signal.aborted); + const scope = chat.chatGPTScope(model.id); await chat.chatGPTLease(scope, false); await assert.rejects(chat.chatGPTLease(scope, true)); + assert.equal((await subscriptionControlsResponse(request({ providerId: "xai", op: "consent", enabled: false }))).status, 200); + assert(nativeBackground.controller.signal.aborted); assert(!nativeForeground.controller.signal.aborted); + const xaiModel = native.nativeSubscriptionModels().find((entry) => entry.provider === "xai-subscription")!; + const nativeScope = native.subscriptionScope("xai", xaiModel.id); await native.subscriptionLease(nativeScope, false); await assert.rejects(native.subscriptionLease(nativeScope, true)); + await subscriptionControlsResponse(request({ providerId: "xai", op: "disconnect" })); + assert(nativeForeground.controller.signal.aborted); assert(!native.nativeSubscriptionConnected("xai")); + assert.deepEqual(JSON.parse(await fs.readFile(path.join(directory, "xai-credentials.json"), "utf8")), { version: 1, allowBackgroundUse: false }); + } finally { foreground.release(); background.release(); nativeForeground.release(); nativeBackground.release(); } +}); + +test("HTTP controls reject cross-origin, simple-form and private credential operations; desktop never falls back", async () => { + const forged = request({ providerId: "chatgpt", op: "consent", enabled: true }); forged.headers.set("origin", "https://unrelated.invalid"); + assert.equal((await subscriptionControlsResponse(forged)).status, 403); + const noHeader = request(); noHeader.headers.delete("x-learnharness-subscriptions"); + assert.equal((await subscriptionControlsResponse(noHeader)).status, 403); + const noOrigin = request({ providerId: "chatgpt", op: "cancel" }); noOrigin.headers.delete("origin"); + assert.equal((await subscriptionControlsResponse(noOrigin)).status, 403); + for (const op of ["lease", "models", "snapshot", "token", "openExternal"]) assert.equal((await subscriptionControlsResponse(request({ providerId: "chatgpt", op }))).status, 400); + const huge = request({ providerId: "chatgpt", op: "cancel", padding: "x".repeat(16384) }); + assert.equal((await subscriptionControlsResponse(huge)).status, 413); + const browser = new Request("http://localhost:3000/api/subscriptions/browser?id=00000000-0000-4000-8000-000000000001"); + assert.equal((await subscriptionBrowserResponse(browser)).status, 403); + for (const [marker, secret] of [["1", undefined], [undefined, "fixture-capability"]]) { + if (marker) process.env.LH_DESKTOP_SERVER = marker; else delete process.env.LH_DESKTOP_SERVER; + if (secret) process.env.LH_DESKTOP_API_SECRET = secret; else delete process.env.LH_DESKTOP_API_SECRET; + assert(!webSubscriptionsEnabled()); + assert.equal((await subscriptionControlsResponse(request())).status, 404); + await assert.rejects(chat.chatGPTRequest("status"), /desktop app/); + } + delete process.env.LH_DESKTOP_SERVER; delete process.env.LH_DESKTOP_API_SECRET; +}); + +function fakeSDK(login: (id: string, interaction: ProviderAuthInteraction) => Promise) { + const factory = (id: string) => { + const provider = id === "xai" ? xaiProvider() : githubCopilotProvider(); + return { ...provider, auth: { oauth: { ...provider.auth.oauth!, login: (interaction: ProviderAuthInteraction) => login(id, interaction), refresh: async () => credentials(id) } } }; + }; + return { createModels, githubCopilotProvider: () => factory("github-copilot"), xaiProvider: () => factory("xai") }; +} +test("device browser actions preserve complete URLs privately, are origin-bound, one-use and cancellation-bound", async () => { + const scratch = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-action-")); + let complete!: (credential: OAuthCredential) => void; + const pending = new Promise((resolve) => { complete = resolve; }); + let notified!: () => void; + const notification = new Promise((resolve) => { notified = resolve; }); + const owner = createWebSubscriptionOwner({ directory: scratch, sdk: fakeSDK(async (_id, interaction) => { interaction.notify({ type: "device_code", userCode: "PUBLIC", verificationUri: "https://auth.x.ai/device?user_code=PUBLIC" }); notified(); return pending; }) }); + try { + const login = owner.control("xai", { op: "login" }, "http://localhost:3000"); await notification; + const interaction = (await owner.native.status()).find((entry: { providerId: string }) => entry.providerId === "xai")!.interaction; + const command = { op: "open", attemptId: interaction.attemptId, actionId: interaction.browser.id }; + const opened = await owner.control("xai", command, "http://localhost:3000"); + assert(opened.browserAction); assert(!JSON.stringify(opened).includes("auth.x.ai")); + const id = new URL(opened.browserAction, "http://localhost:3000").searchParams.get("id")!; + assert.equal(await owner.takeBrowserAction(id, "http://other.invalid"), undefined); + assert.equal(await owner.takeBrowserAction(id, "http://localhost:3000"), "https://auth.x.ai/device?user_code=PUBLIC"); + assert.equal(await owner.takeBrowserAction(id, "http://localhost:3000"), undefined); + const stale = await owner.control("xai", command, "http://localhost:3000"); + await owner.control("xai", { op: "cancel" }, "http://localhost:3000"); + complete(credentials("xai")); await login; + assert.equal(await owner.takeBrowserAction(new URL(stale.browserAction!, "http://localhost:3000").searchParams.get("id")!, "http://localhost:3000"), undefined); + } finally { owner.close(); await fs.rm(scratch, { recursive: true, force: true }); } +}); + +test("web ChatGPT login supplies only an opaque browser action; cancel/replacement invalidates earlier launch", async () => { + const scratch = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-chat-action-")); + const owner = createWebSubscriptionOwner({ directory: scratch, sdk: fakeSDK(async (id) => credentials(id)) }); + try { + const first = await owner.control("chatgpt", { op: "login" }, "http://localhost:3000"); + assert(first.browserAction); assert(!JSON.stringify(first).includes("auth.openai.com")); + await owner.control("chatgpt", { op: "cancel" }, "http://localhost:3000"); + const second = await owner.control("chatgpt", { op: "login" }, "http://localhost:3000"); + assert(second.browserAction); + const firstId = new URL(first.browserAction, "http://localhost:3000").searchParams.get("id")!; + assert.equal(await owner.takeBrowserAction(firstId, "http://localhost:3000"), undefined); + const secondId = new URL(second.browserAction, "http://localhost:3000").searchParams.get("id")!; + const target = new URL((await owner.takeBrowserAction(secondId, "http://localhost:3000"))!); + assert.equal(target.origin, "https://auth.openai.com"); + const callback = new URL(target.searchParams.get("redirect_uri")!); + assert.equal(callback.hostname, "127.0.0.1"); assert.equal(callback.pathname, "/auth/callback"); + assert(target.searchParams.has("state")); assert(target.searchParams.has("code_challenge")); + } finally { owner.close(); await fs.rm(scratch, { recursive: true, force: true }); } +});