Reconcile integration/sim into main (memory, python-fs tools, search-key validation, math rendering, …)#26
Merged
Merged
Conversation
Adds the AndroidJUnitRunner config and androidx.test dependencies so emulator-based instrumented tests can exercise paths that need real native libs (PDFium rendering + ML Kit OCR), starting with PDFExtractorModuleInstrumentedTest.
- Cap recognizeImage decode at 2048px long side on both platforms (inSampleSize pre-pass + manual EXIF rotation on Android, CGImageSourceCreateThumbnailAtIndex on iOS) so full-resolution photos cannot OOM the process. - Cap iOS PDF page render dimension to match Android. - Android: close doc/fd in finally, catch Throwable in OCR paths so a decode OutOfMemoryError rejects the promise instead of killing the process, release the ML Kit recognizer in invalidate(). - Gate .heic attachments on Android 9+ (API 28) where platform HEIF decoding exists. - iOS recognizeImage: reject unparseable file:// URIs and take security-scoped resource access, matching extractText. - Skip page separators for empty pages; normalize file:// in both Android methods; build the unsupported-file alert from getSupportedExtensions() instead of a hardcoded list. - Instrumented test: use BridgeReactContext (ReactApplicationContext is abstract in RN 0.8x). Verified: unit + integration jest suites, tsc, eslint; Kotlin compiles; 3/3 instrumented OCR tests pass on an Android 14 arm64 emulator (ML Kit + pdfium, scanned-PDF fallback and image OCR).
- Guard ML Kit recognizer close behind an in-flight counter so invalidate() (bridge teardown / dev reload) never closes the native detector while an OCR worker holds it across the JNI boundary; the last in-flight worker performs the deferred close. Post-shutdown OCR calls reject cleanly. - Apply the full EXIF orientation matrix (rotation and mirroring, tags 2/4/5/7 included) to decoded bitmaps; InputImage rotation alone cannot express mirrored orientations. - Log doc/fd close failures that would otherwise be silent after the promise has settled. - Instrumented tests: capped decode of a 4400x3400 image, mirrored FLIP_HORIZONTAL EXIF recovery, reject-after-invalidate with idempotent teardown. Verified: 6/6 instrumented tests pass on a physical Pixel 8 (Android 16); Kotlin compiles clean.
Documents reading AsyncStorage (chat transcripts) and rag.db (knowledge base) from the iOS simulator container and via adb run-as on Android debug builds, for E2E verification of features like OCR attachment text.
…atScreen" This reverts commit 2bc776d.
…ction Issue #4: model selection modal disappears when selecting a text model. Root cause: ChatScreen's early return swaps NoModelScreen ↔ main view when hasActiveModel flips. Each renders its own ModelSelectorModal instance. For remote models, setActiveRemoteTextModelId fires synchronously inside setActiveRemoteTextModel, causing the tree swap (and modal unmount) before onSelectionComplete could close the modal. Fix: call onSelectionComplete() BEFORE the async store update in handleSelectRemoteTextModel and handleSelectRemoteVisionModel. This sets showModelSelector=false before hasActiveModel flips, so the modal is already closing when the tree swaps — no visible flash. Also pass onSelectionComplete from both NoModelScreen and ChatModalSection (was missing in both).
- .githooks/pre-commit (active via core.hooksPath): blocks direct commits to main in every worktree, delegates to the husky pipeline when present. Note: .githooks did not exist before, so NO pre-commit hooks were running at all despite CLAUDE.md describing them. - CLAUDE.md: multi-agent coordination rules (one worktree per agent, no branch switching in shared trees, stage only your own files, primary checkout owns Metro/simulator, serialize native builds, coordinate via channels) and restores the transcript-pulling docs. Context: 5 concurrent agent sessions shared one working tree today, causing branch switches under active sessions, 19 unpushed commits on local main, reverted files, torn fast-refresh app hangs, and corrupted native build caches.
A tool-less model answers 'what tools can you use' and 'run this' from training priors — claiming code execution and pretending to produce plots (a real transcript showed exactly this: no tool was ever called, yet the model said 'I'm now running the Python code'). When no tools reach the model this turn, append a one-line note stating it has none and must give code for the user to run instead. Kept to a single sentence since target models can have a 4k context. Consolidates the per-turn tool-prompt logic into buildPromptWithToolNote in the registry, used by both the send and regenerate paths.
ChatMessage passes onRemember since the Remember feature landed, but ActionMenuSheetProps never declared it, breaking tsc on a clean checkout of main. Fix extracted from the memory agent's working-tree WIP (type + menu item only).
# Conflicts: # android/app/src/androidTest/java/ai/offgridmobile/pdf/PDFExtractorModuleInstrumentedTest.kt
# Conflicts: # CLAUDE.md
Review of the no-tools note found it fired on activeTools.length===0, which only counts built-in tools. MCP/extension tools are tracked out-of-band, so a user with an MCP tool but no built-in tools would get a system prompt saying 'you have no tools' while the tool loop simultaneously injected the MCP schema and 'make good use of the tools available' — a self-contradiction every turn. buildPromptWithToolNote now takes hasOtherTools; both call sites pass whether any extension tool is enabled, so the note only fires when the model truly has zero tools. Switched to an options arg to stay within the param limit.
Second half of the type-level unblock for the half-landed Remember feature on main (companion to the ActionMenuSheet prop fix).
Track connectedModalShown per RemoteServer. handleConnectServer shows the success modal only the first time an endpoint connects; later reconnects go straight to Main without the interrupt.
The header model selector rendered a hardcoded 'Models'. It now shows the currently-selected model's name (reusing chat.activeModelName), falling back to 'Models' when no model is resolved.
Conversations now persist the remote serverId alongside modelId. Re-opening a chat restores the model it was created with (restoreConversationModelFn) instead of using whatever model was globally active. New chats use the last selected model (useNewChatModel) rather than resetting to the first downloaded model. The chat list shows each conversation's model name.
Recent conversation cards resolve and display the model each chat used (local downloaded model name, or discovered remote model name, falling back to the stored model id). Adds resolveConversationModelName plus a model row under each card, wired through HomeScreen from downloadedModels and remoteDiscoveredModels. Adds serverId to the conversation test factory.
ChatsListScreen had its own copy of the model-name resolver. Point it at the shared src/screens/conversationModelLabel module so the full chat list and the Recent list on Home resolve model names identically.
Initial route only checked downloadedModels, so a user with no local model
but a configured network server landed on ModelDownload ("Set Up Your AI")
on every launch. Treat a configured remote server as a valid model state:
route to Main when downloadedModels or remote servers exist.
Opening Settings > Web Search fired a validation probe against the provider for the already-stored key, because the validation effect watched apiKey and the keychain load set it on mount. Now validation runs only on an explicit edit (debounced via a ref-held timer + AbortController, cancelled on keystroke / provider switch / unmount). A mounted screen with a stored key shows the neutral 'Stored on device' hint instead of a background-probed verified/rejected badge. Adds an RNTL test asserting no validation on mount and debounced validation on edit.
…e on the forced-final turn When the tool loop hits its iteration/call cap it forces one final text turn. DeepSeek-family remote models (e.g. opencode zen big-pickle) ignore tool_choice:'none' AND still emit tool-call markup even with no tools declared, so the forced turn produced only strippable markup and the user dead-ended on "I could not find any results" despite the data already gathered. Add a second synthesis pass: when the first forced pass yields no prose (just stripped markup), retry with no tools and an explicit in-band "stop searching, answer now from the results above" instruction. Request-level tool_choice does not reach the model's intent to keep searching, but a direct instruction turn does — the model then writes a real answer from the gathered tool results. Verified end-to-end against big-pickle through the snoop proxy with a live harness: the PIV crypto-tax research prompt now returns a full sourced answer instead of the fallback, with no raw DSML markup reaching the user. Also: - Add __tests__/harness/liveToolLoop.test.ts: an opt-in (LIVE_HARNESS=1) harness that drives the real runToolLoop against a real OpenAI-compatible endpoint and real web_search, for fast iteration without a device build. CI skips it. - Fix Brave web_search unit-test fetch mocks that lacked ok/status, which the earlier "surface Brave HTTP errors" commit turned into failures.
…odide MEMFS Give the model the standard coding-agent file tools, operating on the same in-memory Pyodide filesystem run_python uses. A file written by run_python is readable by read_file and vice versa - one shared, per-session scratch workspace. Tools (all backed by pythonRuntimeService.execute): - read_file - contents with line numbers, offset/limit paging - write_file - create/overwrite, auto-creates parent dirs - edit_file - exact-string replace, unique unless replace_all - list_files - directory listing (dirs marked with a trailing slash) - grep - regex content search with optional path/include glob Design: - Each op runs a small Python snippet through the existing execute() bridge and prints a <<FSJSON>> envelope. Args are double-JSON-encoded into the source so arbitrary content (quotes, newlines, unicode) round-trips with no escaping bug or injection. Read/grep are char/match capped so a big file can't overflow the WebView stdout ceiling and truncate mid-parse. - Unlocked as a group by the Python toggle (resolveEnabledToolIds) rather than 5 extra rows: hidden + requiresPython, auto-added to the schema/text-hint when run_python is enabled. Shared ensurePythonRuntimeReady self-heals a missing runtime like run_python. Tests: unit (per-handler formatting + the double-encode contract), integration (JS fake interpreter proving cross-tool shared-workspace coherence), and registry (the unlock expansion). Full suite green. Renames branch focus to the broader python agent-tools feature.
From three review passes:
- edit_file replace_all: read stringified booleans correctly ('false' was
truthy via !!, silently bypassing the uniqueness guard -> mass replace).
- Envelope uses ensure_ascii=False so non-ASCII content doesn't inflate 6-12x
and overflow the 100k stdout cap into a truncated, unparseable JSON.
- Coerce/clamp offset & limit (string '0'/negative no longer mean 'zero lines').
- read_file reports a line that exceeds the size cap instead of '(empty)'.
- Reject empty old_string (str.replace('', x) interleaves the whole file) and
missing write_file content (was silently truncating the file).
- Confine paths to the workspace (no absolute paths / stdlib walks) and wrap
every op so a Python error still emits an envelope (surrogate writes, etc).
- Namespace-isolate the helper (aliased _fs_ imports + _fs_req) so it can't
rebind the user's run_python globals (e.g. a shadowed 'json').
- Fix the trailing-newline off-by-one line count.
- getToolLabel cases for the fs tools (were showing the raw snake_case id).
Adds coercion/validation tests; updates the fake-interpreter contract regexes.
Gated jest harness (ZEN_E2E=1, no API key) that drives the real big-pickle GLM
endpoint through our tool schema + extraction path. Verifies the endpoint accepts
the run_python + filesystem-tool schema and that whatever tool-call format it
returns is recovered into the right tool name + object args by the same code the
app uses. Skipped by default so normal/offline/CI runs are unaffected.
Confirmed live: big-pickle calls run_python for compute and write_file with
{path, content} for file creation - the fs-tool contract holds end-to-end.
…g onFirstToken Adversarial review of 5debebb found two issues in the forced-final synthesis pass: 1. (High) On the LiteRT on-device path the synthesis pass was actively harmful. It appends a synthetic "stop and answer" user turn and drops tools to [], but LiteRT rebuilds model context from the message array: buildLiteRTSendText keys the tool-result text off a TRAILING run of `tool` messages, and buildLiteRTHistory excludes `tool` messages entirely. The appended user turn hides the trailing tool run, and tools:[] flips toolsChanged → a native session reset. Net: the model is told to synthesize "from the tool results above" against a freshly reset session with no tool results in context — worse than the pre-change fallback. The synthesis pass is a remote-parser remedy (DeepSeek models whose server-side parser is gated on `tools` and ignores tool_choice); LiteRT does native tool calling and never has that problem. Gate the pass behind !isLiteRTActive(), restoring pre-change on-device behavior. Adds a unit test asserting LiteRT does not run the extra pass. 2. (Low, latent) runForcedPass reset firstTokenFired per sub-pass, so a streaming second pass could re-fire onThinkingDone/onFirstToken for the same turn. No caller wires onFirstToken to the tool loop today so it is currently a no-op, but it would double-count a future TTFT metric. Reset firstTokenFired once per forceFinalTextResponse instead. Remote path unchanged and re-verified live against big-pickle (full synthesized answer, no DSML). Full jest green (6308).
…ver name Chat model picker would not open: ChatScreen opened the picker synchronously while the MODELS manager sheet was still dismissing, and presenting a second modal mid-dismiss wedges iOS's modal system so the picker never appeared. HomeScreen already deferred the picker open until the sheet's onClosed fired; ChatScreen never got that treatment. Mirror the same closeManagerThen / runPendingAfterClose / onClosed pattern in ChatScreen. Also surface the remote server name next to the model for remote models: - ModelsManagerSheet gains an optional subLabels prop rendering a second line under a row's value; chat + home pass the active text server name. - The empty-chat line reads 'begin chatting with <model> on <server>'. - activeServerName is derived in useChatScreen / useHomeScreen from the active model's serverId via the remote server store. Updates the ChatScreen test's ModelsManagerSheet mock to fire onClosed so the deferred-open flow is exercised.
…corrupt each other The OpenAI-compatible streaming provider accumulated tool-call deltas by "start a new call whenever an id arrives, append all argument fragments to the most recent call." That only holds when a model streams its tool calls contiguously (all of call 0, then call 1). Models that emit several tool calls at once — GLM 5.2, Kimi K2 — stream them INTERLEAVED and keyed by `index` (index 0 frag, index 1 frag, index 0 frag, …). The old logic concatenated both calls' JSON arguments onto one call, so JSON.parse of the arguments threw "Unexpected end of JSON input" and the whole turn crashed. Fix processToolCallChunk to accumulate by `tc.index` (the standard OpenAI streaming contract), creating/locating the slot per index and appending arguments there, with the previous id/last-call behavior kept as a fallback for providers that omit index. Verified live via the harness against Fireworks: GLM 5.2 and Kimi K2.6 now complete the PIV research prompt with full, sourced answers (previously crashed); gpt-oss-120b and deepseek-v4-pro (which don't interleave) are unaffected. Adds unit tests for interleaved-by-index accumulation and for an id that first appears on a later fragment. Also hardens the live harness's XHR polyfill to finalize on a post-data connection close (like real XHR) instead of erroring, which is how some upstreams end a stream.
Shows EMA-smoothed download speed (e.g. '2.5 MB/s') alongside the existing bytes-progress text in all download progress UIs: - ModelCard (used by TextModelsTab, ImageModelsTab, ModelDownloadScreen) - DownloadManagerScreen ActiveDownloadCard Speed is computed in downloadStore from combined bytes-delta between progress events using an exponential moving average (alpha=0.3) and is cleared on terminal/processing state transitions. Also adds downloadBytes to ModelDownloadScreen cards that were previously missing it. New util: src/utils/formatSpeed.ts Tests: 6 formatSpeed + 8 downloadStore speed/clearing + 3 ModelCard integration + 1 DownloadManagerScreen integration.
Assistant messages render Markdown but showed $...$ / \(...\) as raw text. Add a markdown-it tokenizer for $...$ / $$...$$ and \(...\) / \[...\] math and render the tokens as MathJax-generated SVG (react-native-mathjax-svg, which bundles MathJax es5 - fully offline, no WebView). - src/utils/markdownItMath.ts: vendored dollar/bracket math tokenizer (no katex dep); delimiter checks reject currency ($5 and $3) and escaped \$. - src/components/MathText.tsx: MathJax wrapper with theme color/size and an error boundary that falls back to raw TeX on malformed input. - src/components/MarkdownText.tsx: register the plugin + math_inline/math_block rules; preprocessMarkdown now skips math spans so multiplication-asterisk escaping can't corrupt a formula. - jest.setup.ts: mock the heavy MathJax module. - 38 tests across a new unit suite + the RNTL suite.
The Pyodide filesystem was RAM-only and wiped on every interpreter reload. Now:
- Both run_python and the fs tools work in a persistent /workspace (cwd), which
the runtime service snapshots to native storage (react-native-fs) after each
run (debounced) and on shutdown, and restores on the next run - so files
survive a reload/app restart.
- Scope is PER PROJECT, shared across a project's conversations (matching the RAG
KB model): the projectId flows from the tool call through execute(); a call for
a different project than the one loaded snapshots the old and restores the new
(restore clears first, so nothing bleeds across projects). No-project chats
share a global workspace.
- The snapshot/restore/zip cross the WebView bridge via dedicated messages
(outside the 100k stdout cap) with the manifest as JSON {path, base64}.
- New 'Export Python workspace' button in Settings > Tools writes a .zip and
opens the share sheet.
Tests: per-project restore/swap/isolation/export integration (control-aware fake
page), page-source setup, and the export button. Full suite green.
Add react-native-share so the .zip export attaches a real file on Android too (RN's built-in Share can only share text there). shareLocalFile prefers it and falls back to the built-in Share when the native module isn't linked yet, so the export works before a rebuild and gets more robust after. NOTE: react-native-share is a native dependency - needs npm install + pod install (iOS) / gradle sync (Android) to activate. Until then the fallback path runs.
…e-drain fix Stall watchdog (generationStallWatchdog.ts): races each local/remote generation against a 60s no-progress timer; on timeout it aborts the engine, clears the stuck streaming bubble, and rejects so the UI shows an error instead of spinning forever. Re-armed on token flush; paused during tool execution so a slow tool is not misread as a stall; reset after the tool-routing pass. Reentrant no-op calls (retry on an older message, double-tap send) never touch the live watchdog. Mid-turn steering: runToolLoop folds messages queued for the generating conversation into the current turn between tool-call rounds, with a fresh capped iteration/tool budget. Display text stays separate from model context (no document dump in the bubble). Skipped when aborted. LiteRT falls back to a next-turn message (documented). Queue-drain fix: processNextInQueue drains one conversation per pass (no cross- conversation merging) and guards on isGenerating; setQueueProcessor re-drains messages stranded while no screen owned the processor.
Add the conversation's project name after the model name in the Home screen Recent list, matching the chats list. The meta row reads [model] . [folder] [Project name], with the project name in the primary accent color. Resolved via useProjectStore.getProject so no new prop threading through HomeScreen is needed.
Add an in-app WebView preview so a self-contained HTML file the agent writes to the Python workspace (e.g. a minigame) can actually run on the device, instead of only being exportable as a .zip. - pythonPage: new __fsReadFile control op reads one workspace file's full text over the control channel (bypasses the stdout cap, so a large page survives), path-confined to the workspace and size-capped at 8 MB. - pythonRuntimeService.readWorkspaceFile(path, projectId) loads the right project workspace first (mirrors exportProjectZip), then reads the file. - HtmlPreviewScreen renders the file in an isolated WebView: JS + DOM storage on so a game runs and keeps state, no message bridge back to the app, and top-frame navigation to a remote origin is blocked. - Chat surfaces an 'Open in browser' button on write_file/edit_file results whose path ends in .html; onOpenHtml threads through MessageRenderer like onImagePress and navigates with the conversation's projectId. - jest.setup stubs react-native-webview globally so suites importing the navigator load; tests asserting WebView props override it locally.
The on-device interpreter is a WebAssembly sandbox with no OS processes, shell, or network sockets, so models that try subprocess/os.system/git clone or requests/urllib hit 'emscripten does not support processes' (Errno 138) and give up. Two changes so the agent uses the primitives that actually work: - run_python description now states the sandbox has no processes/shell/sockets and tells the model to fetch remote files/scripts/skills with the read_url tool (raw file URL) then write_file them into the workspace and import/exec - never git clone or shell out; install PyPI packages via the packages arg. - handleRunPython appends a corrective [hint] when an error looks like a blocked process or socket operation, so the tool loop self-recovers on the next turn instead of repeating the failing approach.
Chat text is drawn in the monospace Menlo font, which has no emoji glyphs, so emoji in assistant markdown and user messages fell back to the OS missing- glyph box (the tofu ? square). Split text into emoji / non-emoji runs and render emoji runs in the system font (Apple Color Emoji / Noto Color Emoji), keeping monospace for everything else. Non-emoji text renders identically. - src/utils/emojiText.ts: splitEmojiRuns/hasEmoji (emoji-regex, keeps ZWJ families, skin tones and flags as single runs) - src/components/EmojiText.tsx: renderEmojiRuns helper + EmojiText <Text> drop-in - MarkdownText: emoji-aware text rule (assistant path) - MessageContent: user bubbles use EmojiText - emoji-regex promoted to a direct dependency (already present transitively)
The message FlatList is not inverted (newest at the bottom), but it set maintainVisibleContentPosition.autoscrollToTopThreshold: 100. On a non-inverted list that makes it snap to the real top whenever a content-size change lands the anchor within the threshold of the top - which is exactly what happens when a reply finalizes its markdown, so the view intermittently jumped back to the beginning of the conversation. Drop autoscrollToTopThreshold (an inverted-list setting) and keep minIndexForVisible: 0 so content changes above the viewport still don't shift the read position. Add a regression test asserting the config.
The default system prompt says the assistant is 'running locally on the user's device' and 'prioritizes user privacy'. That is false when the active model is a remote endpoint - requests go to the server the user connected to - so the model would answer questions about itself dishonestly. Add APP_CONFIG.defaultSystemPromptRemote (no local/privacy claim, states it answers through a remote endpoint the user connected to) and select it in resolveBaseSystemPrompt when the generation is remote. A user/project custom prompt still always wins. Extracted the resolver to baseSystemPrompt.ts (keeps useChatGenerationActions under the line limit) with unit tests for the selection logic and the honesty of both default prompts.
…ite_file" This reverts commit 179708b.
…-port-integration-sim # Conflicts: # __tests__/integration/tools/searchProvider.test.ts # __tests__/rntl/components/GenerationSettingsModal.test.tsx # __tests__/rntl/screens/DownloadManagerScreen.test.tsx # __tests__/rntl/screens/ModelSettingsScreen.test.tsx # __tests__/unit/services/tools/registry.test.ts # __tests__/unit/services/tools/search/searchProviders.test.ts # __tests__/utils/testHelpers.ts # src/components/GenerationSettingsModal/index.tsx # src/screens/ChatScreen/ChatModalSection.tsx # src/screens/ChatScreen/ChatScreenComponents.tsx # src/screens/ChatScreen/index.tsx # src/screens/ChatScreen/useChatGenerationActions.ts # src/screens/ChatScreen/useChatScreen.ts # src/screens/ChatsListScreen.tsx # src/screens/DownloadManagerScreen/items.tsx # src/screens/HomeScreen/components/RecentConversations.tsx # src/screens/SettingsScreen.tsx # src/screens/WebSearchSettingsScreen.tsx # src/services/generationServiceHelpers.ts # src/services/tools/handlers.ts # src/services/tools/search/braveProvider.ts # src/services/tools/search/index.ts # src/services/tools/search/serperProvider.ts # src/services/tools/search/types.ts # src/stores/appStore.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Forward-ports the whole
integration/simfeature set onto currentmain, reconciling the two divergent evolutions of the app. Large merge — review before merging. tsc clean, lint clean (0 errors), full suite green except one pre-existing flaky test.Why this exists
mainandintegration/simhad diverged:maincarried the squashed#21bundle plus later fixes (#9/#11 inline generation errors, download-speed crash fix, Add-Server, mmproj integrity #23), whileintegration/simkept granular history and added a large feature set. A plain merge produced 25 conflicting files across two independent refactors of the generation pipeline, so this was resolved deliberately, per-file.What integration/sim brings in
read_file/write_file/edit_file/list_files/grep)Conflict resolution (kept from
main)useChatGenerationActions; both catch paths now callhandleGenerationFailure)!!(...)crash guard; Add-Server on NoModelScreengetLastVisibleMessagein conversation previews (kept alongside integration'sresolveConversationModelNameextraction)resetAfterGenerationError, does not false-offline the server)Notably
integration/simhad independently fixed the same #9 stuck-spinner bug (tearDownFailedRemoteGeneration); reconciled to main's reviewed version.Bugs found & fixed during the merge
finalizeStreamingMessagein the remote-tools success path (both main's and integration's finalize blocks would have run) — collapsed to one.ErrorMessageinto its own component to keep files within the 500-line limit.Validation
tsc --noEmit: cleaneslint src: 0 errors (pre-existing warnings only)downloadStoreEMAtoBeCloseToflake (unmockedDate.now, fails on base too — not a source bug)Once this merges, the small feature branches built on
integration/sim(gate-empty-kb-memory-tools, memory-command-dedupe, recent-project-name, online-tools-switch, edit-message-selection, html-workspace-preview, remote-endpoint-system-prompt) rebase cleanly and can be PR'd normally.