A connect that fell back to typed model ids replaced the stored model list
with the typed ids alone, then pruned the user pins against it. A transient
/models failure (503, timeout) therefore deleted every discovered model, its
detected metadata, and its pins, and answered 200.
- DiscoverResult carries the endpoint's HTTP status. A 401/403 fails the
connect even when ids are typed: a rejected key is not a missing model list.
- A failed probe keeps the stored snapshot for the SAME base URL, so the
prune cannot delete pins for a probe that failed. A new base URL starts
clean, so an older endpoint's models never follow the URL.
- Comment corrections: the route contract and the CustomProviderSettings
fields described the behaviour from before the manual-ids change.
- tests/custom-provider-route.test.ts covers the four branches: transient
failure, rejected key, new endpoint with no model list, working probe.
tsc and eslint clean. Suite: 2554 tests, 2 failures, both also failing on
6959482 (render-sidebar date drift, subscription-auth install artifact).
Hosted OpenAI-compatible APIs often serve chat but not GET /models
(Volcengine Ark Agent Plan returns 404), so connect failed. Accept a
typed list of model IDs: merged into discovered models when /models
works, used alone when it does not. The typed list is stored as
customProvider.manualModels so reconnects and pin saves keep it.
A local endpoint that publishes no limits — LM Studio's /models says nothing —
left every model on the 8192/4096 defaults with no way to say "this one has a
131k window". Each discovered model now gets three optional pins in the
endpoint editor: context window, max output tokens, thinking (auto/on/off).
A pin always wins: user pin → what the endpoint published → compiled default.
Blank means no opinion, so discovery keeps ruling wherever you don't pin. Pins
live in customProvider.overrides keyed by model id, stored apart from the
discovery snapshot: a reconnect refreshes the models and prunes the pins of
models that vanished while keeping the rest.
Pins are written by PATCH /api/custom-provider, which never probes — the whole
point is configuring a server that isn't answering /models correctly. Connect
still probes and still fails loudly when the endpoint is down. A pin under 2048
tokens is rejected on the write side and clamped up on the read path, matching
the compaction-floor discipline.
Custom models were pinned reasoning:false, so every one of them offered a
single thinking level ("off") while built-in reasoning models offered the
standard ladder. The endpoint's /models metadata now decides:
- detectReasoning() trusts explicit metadata only — a boolean
reasoning / supports_reasoning / thinking, or a reasoning token in
supported_parameters (vLLM, OpenRouter), capabilities, tags or
architecture.output_modalities. No name guessing: a false positive would
send reasoning_effort to a server that rejects it.
- CustomModel carries the detected flag (persisted, sanitizer keeps it),
toPiModel passes it through, and pi-ai does the rest — default
off/minimal/low/medium/high set, reasoning_effort on the wire.
Verified in the browser against a local OpenAI-compatible mock: the
reasoning-capable model exposes off/minimal/low/medium/high, the plain one
stays at off, and a prompt with medium actually arrives as
reasoning_effort=medium.
Review pass: shorten verbose doc comments across the custom-endpoint code,
merge duplicate imports, share one synthetic provider entry between the keys
section and setup dialog, collapse the duplicated connect-editor block, revert
an unrelated settings-route drive-by.
Preserve original contributor commit 3bb28e5f29. Resolve only the settings-dialog conflict by retaining current General/update behavior: the donor General-only language placement is explicitly superseded by the human-approved row BELOW the full-width Deepgram key. The assigned implementation worker will implement that placement and approved shared dropdown finishing. Foundation only, not a checked candidate.
Keep failed attempts in the saved transcript and omit only trailing failures from Retry's temporary agent history. Use the matching temporary prefix length when persisting genuinely new output.
Preserve the error notice across plan/compaction markers, refuse Retry after later compaction, and avoid synthesizing results for tool calls inside failed generations while retaining Pi's native filtering.
Verified: 173 focused Retry/session/compaction-projection/practice-record tests, typecheck, changed-file lint, and diff --check. No app launch or real provider calls.
Root cause (postmortem, verified live): Chromium caps 6 concurrent
HTTP/1.1 connections per origin. Every running turn pinned slots for
its whole life — the POST /api/chat response stream plus a reattach
GET stream per visit that never closed on navigate-away — so two
concurrent thinking sessions starved navigation RSC fetches in the
browser queue (147s measured) until the turns settled. Server was
exonerated (3ms curls during the freeze); main thread idle throughout.
- COMPACTION LAW + WIRE DIET (ui-stream): store the states, not the
chunks — replace-in-place snapshot retention keyed by updateSlots,
~100ms coalesced latest-wins fan-out, slim wire drops pi's duplicate
assistantMessageEvent payload.
- DIET B: POST /api/chat acks and closes ({ok:true}) — the turn wire
is GET /stream for everyone, submitter included; send() reattaches
via refetch-first resumeStream(true).
- DIET A: unmount cleanup calls model.stop() — navigating away aborts
the view connection (turns are server-side background objects).
- Answer-flicker fix: resumeStream's eager tail clear moved off the
refetch path (the refetch swaps base+tail atomically; clean-slate
guard restored on refetch failure) — the optimistic answer card
survives refetch latency (1.7s stalls measured on dev).
- Tests converted to the ack contract (drainTurn/drainRunning helpers,
dietFetch mock) + a pin that the POST body reads while the turn runs.
Gates: 1489/1489, typecheck, lint. Live: dual streaming sessions,
navigations 335-648ms, in-page settings 19ms (was 147s).
The settings Graph section splits into two switches (owner, 2026-10-06):
- EXTRACTION (was the lone "Graph" row): the DATA channel — pipeline,
auditor, prober graph tools. Key unchanged (graph.enabled), label now
names its job; off = the graph stops growing, nothing is deleted.
- GRAPH RENDERING (new, graph.rendering, default on): display-only.
Off = the home's main view is the CHAINS VIEW instead of the canvas;
data, reasoning-chain model, and the pipeline keep working untouched.
The chains home (components/chains-home.tsx): ChainTableBody in flow —
same body as the overlay, same powers (query + sort, selection, the
delete fence with its confirm dialog), but onClose is absent so the
top-bar X does not render (the table IS the page; ChainTableBody now
takes onClose optionally). The chrome survivors float top-right below
the top bar: Import notes (ALWAYS — the entry-point law) + the cost
pill (GraphCostControl, now exported). The composer blob persists over
everything; the table scrolls CLEAR of it via a fixed pb-40 band (text
under a floating box is noise — unlike the canvas, which runs under).
Pick flow mirrors the overlay contract exactly (home lifts the pick
set; selectMode swaps the bar's confirm to Done). The chip relabels to
"Pick propositions" (no "from the map" — there is no map); while
picking, the chip row hides so the floating bar above the composer is
the one voice. Settings resolve rendering through the one core
resolver + renderingEnabled(); read server-side per navigation, so the
switch applies on the next visit home.
Smoke-verified live (headless browser): chains home renders without the
X, import + search + composer clearance confirmed, pick flow checked 2
propositions -> Done -> chip count -> practice session started with the
graph context carried (2 props, 2 chains), settings restored.
Known follow-ups (flagged, not built): after Done the bar morphs to the
table's Clear/Delete management bar while the chip also shows the count
(two voices, same set — the table's own editor law); /chat/new's pick
chip still says "from the map".
Tests: rendering in factory/sanitize/parse/resolve pins, the GraphSection
render pins (Extraction label + rendering row), the home-variant
chain-table pin (no close X). 1414 pass; tsc, lint, build green.
Diagnosed (owner report 2026-10-06, 'splattered from the middle on
every navigation'): the canvas's world is 350 nodes — 262 stored
propositions + 88 derived arg_ implication nodes the chain arrows
bend through — but the drag-commit sanitizer accepted only stored
proposition ids, and writePositions REPLACES the file: every
drag-commit purged the arg_ entries, so every mount re-seeded them
on a ~1306-unit ring (seedMissingNodePositions) — arrows streaking
out of the middle, bbox inflated, fit zoomed out. A drag annealed
them back (impLinks springs), the commit stripped them again.
Fix: the sanitizer's known set = deriveView(getGraph()).nodes —
propositions AND arg_ ids (stable: minted from the chain ledger).
The grew-check becomes the honest test — some CURRENT view node
absent from the payload (a merge landed mid-drag) → exactly one
absorbing settle; full coverage → none (the old proposition-count
comparison broke once args counted).
Live ledger backfilled once: 88 arg_ positions seeded at their
chains' centroids, propositions byte-identical (no settle, no
re-anneal). Pinned in tests/commit-positions.test.ts (hermetic
LH_DATA_DIR): args persist, unknown ids drop, no-settle on full
coverage, absorbing settle on a stale payload, garbage sanitizing.