Skip to content

refactor(parse-once): single-grammar reasoning + tool-call parsing, native-first for Gemma#510

Open
alichherawalla wants to merge 527 commits into
mainfrom
refactor/parse-once-boundary
Open

refactor(parse-once): single-grammar reasoning + tool-call parsing, native-first for Gemma#510
alichherawalla wants to merge 527 commits into
mainfrom
refactor/parse-once-boundary

Conversation

@alichherawalla

@alichherawalla alichherawalla commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

What this delivers

Started as the parse-once boundary refactor (single-grammar reasoning + tool-call parsing, native-first for Gemma) and grew into a broad device-hardening pass: every Bxx/Qxx/Dxx/Mxx finding from the on-device test log driven to a fix with an integration test, plus a full rework of model-loading/memory-residency.

Memory / residency (3-mode loading + OOM safety) — the biggest change

  • Three loading modes (Conservative / Balanced / Aggressive), selectable in chat + global settings, wired through loadPolicySync to the one owning modelResidencyManager.
    • Conservative = one heavy model at a time; Balanced (default) = co-reside heavies while they fit the budget; Aggressive = co-reside within a larger RAM budget.
  • Load Anyway is unconditional — a refused load always offers "Load Anyway", which evicts everything else and loads (no survival floor). No dead-end "not enough memory" card.
  • Clean vs dirty budget — clean (mmap GGUF) weights page and are bounded by physical RAM; dirty (CoreML/ONNX/LiteRT) weights can't page, so aggressive's larger fraction does not apply to them (a 9GB dirty model refuses even in aggressive — M6). Android reclaim credit retained (fit a big model on 12GB — B1); the iOS jetsam guard against piling a sidecar onto a dirty compile spike is retained.
  • Sidecars: STT (whisper, up to ~1.5GB) is a full budget participant; TTS (~82MB) and embedding (~90MB) are exempt/co-reside.
  • honor-fits (ensureResident no longer loads when the gate refuses) + failed-unload guard (a resident whose native unload rejects is not counted as freed → no over-commit).
  • New data-driven residency matrix test: co-reside/swap/refuse across all 3 modes for text/image/STT/TTS/embedding permutations.

Device-bug fixes (each with a UI-driven integration test)

  • Voice/STT: chat-mode dictation transcribes the recorded file (realtime→file fallback, B26/B28) and lands in the composer; voice notes carry the transcript (Q20).
  • Downloads: interrupted image/STT downloads surface as retriable (no phantoms) after relaunch (D1/V3/D4); fixed an image-recovery regression from a temp wire-capture commit.
  • Knowledge base: an embedding failure now aborts + rolls back + shows an error card with Retry (no half-indexed doc).
  • Generation: a fatal engine error clears the spinner immediately instead of retrying for ~2 min (B13); silent max-predict cutoff indicator (B15); durable inline runtime-error surfacing (B23).
  • Image: guidance-scale default 7.5 (Q7), image-size no longer clamped/diverged (Q13/Q12), intent-routing hydration fix.
  • Remote: prompt-enhancement routes through the active remote engine (B30); dedicated reasoning_content always surfaced (B16/B17).
  • Projects / thinking / attachments: KB-tool gating, cascade delete, thinking render mid-stream, attachment fullscreen preview (B33), promo sheet once-per-session.

Testing & hygiene

  • Integration-first, /tests doctrine: mount the real screen, drive real gestures, assert the rendered artifact; fakes only at the device boundary; no mocking of our own code. 7 mockist unit tests deleted.
  • Full suite green (~7,750 tests); tsc clean; ESLint 0 errors (max-lines/max-params/complexity cleared via behavior-neutral extraction); production Android bundle builds.

Not verified in CI (needs the on-device pass)

  • Memory/OOM behavior and native STT capture are proven at the JS-decision layer over fakes; the native outcomes (jetsam, mic capture, forced-load survival) require the on-device / Provit pass. Tracked for device sign-off.

🤖 Generated with Claude Code

@alichherawalla

Copy link
Copy Markdown
Collaborator Author

Self-audit

SOLID / abstraction

  • Enough to abstract? Yes — 3 reasoning formats + 3 tool-call openers through multiple consumers = real seams. Two single-source grammars (REASONING_DELIMITERS, TOOL_CALL_OPENERS/CLOSERS); every consumer derives from them.
  • SRP / DIP: parsers are pure (zero-IO) in utils/messageContent; the parse-once boundary is chatStore.finalizeStreamingMessage; renderers consume parseModelOutput, never re-parse. No kind===/instanceof/Platform.OS-mechanism branch introduced.
  • Single source of truth: the two grammars; contract tests import the constants and assert against them (no re-hardcode). parseThinkingContent keeps format-specific regexes but is coupled to REASONING_DELIMITERS by a contract test so it can't drift.
  • Verdict: clean. Justified note — native-first uses isGemma4 ? 'auto' : 'deepseek' inside the owning param-builder (a capability decision in the right layer, not a renderer branch).

Tests — no false green

  • Unit: contract tests drive the REAL parseThinkingContent + REAL ThinkTagParser + REAL ToolCallTokenFilter + REAL stripControlTokens.
  • Integration: reasoningPipeline — REAL store (stream→finalize) + REAL ChatMessage render, nothing mocked in the parse path; asserts the persisted row AND the rendered terminal artifact (getByText/queryByText).
  • Mocks: none in the parse path. Deleting a parser fails these tests.
  • Fails-before/passes-after: channel-leak red, <tool_call: leak probe red, Gemma-auto red — all green after.

Provit (on-device E2E)

  • Journey: pending — the native-first flip needs a Gemma4 thinking + tool-call run on the Android dev build (ai.offgridmobile.dev) + iOS, checking Documents/offgrid-debug.log for [GEMMA-FALLBACK].
  • Run: NOT YET RUN — this is a candidate, not confirmed. Must pass before this ships in a beta (§H). The Steps A–C consolidation is behavior-neutral and fully covered by jest; only the native-first flip requires the device gate.

Platform parity

  • iOS + Android: parsing is pure TS shared by both; the native-first flip goes through the one llama.rn completion contract. No leaked if (ios).

Standards

  • No UI/copy touched (one dev-log line, not user-facing).

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR centralizes reasoning and tool-call parsing into shared utilities used by streaming, storage finalization, and ChatMessage rendering; adds platform-aware override-floor logic for the “Load Anyway” path; unloads both text-model engines on model switches; and removes Whisper’s eager auto-load effect.

Changes

Unified reasoning and tool-call parsing

Layer / File(s) Summary
Shared parsing grammar
src/utils/messageContent.ts, src/components/ChatMessage/utils.ts, src/components/ChatMessage/types.ts
Adds shared reasoning/tool-call delimiter sets and unified parsing exports, and re-exports ParsedContent from the shared module.
Streaming and finalization
src/services/providers/openAICompatibleStream.ts, src/stores/chatStore.ts
ThinkTagParser now handles all configured reasoning delimiters, and chat-store finalization uses one parseModelOutput call for stored reasoning and answer text.
ChatMessage rendering
src/components/ChatMessage/index.tsx, src/components/ChatMessage/styles.ts
ChatMessage now reads parsed content from shared message-data helpers, and the thinking block wrapper uses full width.
Tool-call token filter
src/services/llmToolGeneration.ts, src/services/generationToolLoop.ts
ToolCallTokenFilter is exported and refactored to use shared opener/closer sets, with a Gemma fallback log message added.
Gemma 4 reasoning_format
src/services/llmHelpers.ts, __tests__/unit/services/llmHelpers.branches.test.ts, __tests__/unit/services/llmToolGeneration.test.ts
buildThinkingCompletionParams returns 'auto' for Gemma 4 when thinking is enabled, with matching test updates.
Contracts, integration tests, and docs
__tests__/integration/chat/*, __tests__/unit/utils/*grammar*, __tests__/rntl/components/*, __tests__/unit/components/ChatMessage/parseModelOutput.contract.test.ts, __tests__/utils/factories.ts, docs/GAPS_BACKLOG.md
Adds parser contract tests, integration/regression coverage for reasoning and tool-call leakage, Jest mock updates, factory support for reasoning content, and backlog notes.

Android override memory budget

Layer / File(s) Summary
Platform-aware override survival floor
src/services/memoryBudget.ts, src/services/modelResidency/index.ts
Adds ANDROID_OVERRIDE_SURVIVAL_FLOOR_MB and overrideSurvivalFloorMB(), and updates makeRoomFor to compute the refusal threshold dynamically.
Override floor tests and backlog notes
__tests__/unit/services/modelResidency.test.ts, docs/GAPS_BACKLOG.md
Adds Android/iOS override-floor tests and backlog entries for stale chat state and a reverted ZRAM-swap credit.

Cross-engine text model unload

Layer / File(s) Summary
Unload previous engine on model switch
src/services/activeModelService/loaders.ts, __tests__/unit/services/activeModelService.loaders*.test.ts
Adds unloadPreviousTextModel to unload both LiteRT and llama engines during switches, with regression tests and updated warning text.

Whisper on-demand loading

Layer / File(s) Summary
Remove Whisper auto-load effect
src/hooks/useWhisperTranscription.ts, __tests__/unit/hooks/useWhisperTranscription.test.ts
Removes the mount-time auto-load effect for Whisper and updates tests to assert loadModel is not called on mount.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant openAICompatibleStream
  participant chatStore
  participant messageContent
  participant ChatMessage

  openAICompatibleStream->>chatStore: onReasoning / onToken
  chatStore->>messageContent: parseModelOutput(streamingMessage, streamReasoning)
  messageContent-->>chatStore: reasoning + answer
  chatStore-->>ChatMessage: finalized message
  ChatMessage->>messageContent: buildMessageData(message)
  messageContent-->>ChatMessage: displayContent, parsedContent
Loading
sequenceDiagram
  participant doLoadTextModel
  participant unloadPreviousTextModel
  participant liteRTService
  participant llmService

  doLoadTextModel->>unloadPreviousTextModel: switch detected
  unloadPreviousTextModel->>liteRTService: unloadModel()
  unloadPreviousTextModel->>llmService: unloadModel()
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.63% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description does not follow the required template and omits sections like Summary, Type of Change, Checklist, Related Issues, and Additional Notes. Rewrite the PR description using the repository template, filling in Summary, Type of Change, Screenshots, Checklist, Related Issues, and Additional Notes.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main refactor: shared reasoning/tool-call parsing and Gemma native-first behavior.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/parse-once-boundary

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Parse-once boundary: unify reasoning/tool-call grammars; native-first Gemma

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Centralizes reasoning + tool-call delimiter grammars to prevent parser drift and markup leaks
 (DR1/DR7).
• Parses model output once at finalize/render/stream boundaries via shared parseModelOutput.
• Fixes tool-call message rendering to show pre-tool-call reasoning from reasoningContent (OD14).
• Switches Gemma4 to reasoning_format:'auto' to prefer llama.cpp native parsing with fallback
 logging.
• Adds contract + integration tests to lock invariants and reproduce prior leak regressions.
Diagram

graph TD
  RAW["Raw model output"] --> GRAMMAR["messageContent.ts grammars"] --> LOCAL["chatStore finalize"] --> STORE[("Message store")]
  RAW --> REMOTE["ThinkTagParser stream"] --> STORE
  STORE --> RENDER["ChatMessage buildMessageData"]
  GRAMMAR --> FILTER["ToolCallTokenFilter"]
  NATIVE{{"llama.cpp native parse"}} -."fallback when native empty".-> GRAMMAR
  subgraph Legend
    direction LR
    _p["Process"] ~~~ _s[("Store")] ~~~ _e{{"External runtime"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Adopt OpenAI SDK for remote streaming/tool parsing
  • ➕ Potentially reduces custom streaming parsing code
  • ➕ Standardizes remote API handling
  • ➖ Requires fetch-stream polyfills / global monkey-patching in RN
  • ➖ Does not eliminate inline-tag parsing needs (/ etc.)
  • ➖ Doesn’t cover Ollama NDJSON or existing XHR-SSE paths cleanly
2. Keep multiple parsers but add more tests around each call site
  • ➕ Lower refactor churn in the short term
  • ➕ Less risk of changing shared utilities
  • ➖ Doesn’t remove the drift class; formats can still diverge across parsers
  • ➖ Hard to ensure all ~16 call sites remain consistent over time
3. Defer Gemma `reasoning_format:auto` until after grammar refactor ships
  • ➕ Avoids bundling an unverified runtime behavior change with refactor
  • ➕ Simplifies rollback if device behavior differs
  • ➖ Delays native parse benefits and potential reduction of hand-parse paths
  • ➖ Requires a follow-up PR to flip and validate on-device

Recommendation: The chosen approach (single shared grammars + parse-once at boundaries) is the right long-term fix because it eliminates the drift bug class structurally and is enforced by contract tests. Keeping the Gemma native-first flip explicitly gated behind on-device verification is the correct risk-management choice; if you want to minimize release risk further, consider landing the grammar unification first and flipping reasoning_format:auto in a small follow-up once device logs confirm [GEMMA-FALLBACK] never fires.

Files changed (24) +819 / -261

Enhancement (2) +15 / -4
generationToolLoop.tsAdd Gemma fallback log when hand-parsing tool calls +4/-0

Add Gemma fallback log when hand-parsing tool calls

• Adds a diagnostic log when native tool_calls are empty but raw tool-call markup is present and hand-parsing is used, to validate on-device whether 'reasoning_format:'auto'' covers Gemma natively.

src/services/generationToolLoop.ts

llmHelpers.tsNative-first Gemma: reasoning_format none→auto when thinking enabled +11/-4

Native-first Gemma: reasoning_format none→auto when thinking enabled

• Updates 'buildThinkingCompletionParams' so Gemma4 uses 'reasoning_format:'auto'' when thinking is enabled, allowing llama.cpp to detect chat_format and populate 'reasoning_content'/'tool_calls' natively while keeping the hand-parse fallback path.

src/services/llmHelpers.ts

Bug fix (3) +87 / -30
index.tsxFix ToolCallWithThinking to use shared buildMessageData parsing (OD14) +8/-3

Fix ToolCallWithThinking to use shared buildMessageData parsing (OD14)

• Stops parsing tool-call messages directly from 'message.content' and instead uses 'buildMessageData(message).parsedContent', which honors both separate-channel 'reasoningContent' and inline '<think>', preventing pre-tool-call thinking from disappearing.

src/components/ChatMessage/index.tsx

llmToolGeneration.tsGeneralize ToolCallTokenFilter to shared opener/closer grammar (DR7) +42/-14

Generalize ToolCallTokenFilter to shared opener/closer grammar (DR7)

• Exports 'ToolCallTokenFilter' and reworks it to suppress tool-call blocks using 'TOOL_CALL_OPENERS'/'TOOL_CALL_CLOSERS', including the '<tool_call:' colon form and multiple closers, with robust partial-tag buffering for streaming tokens.

src/services/llmToolGeneration.ts

openAICompatibleStream.tsGeneralize ThinkTagParser to shared REASONING_DELIMITERS (DR1) +37/-13

Generalize ThinkTagParser to shared REASONING_DELIMITERS (DR1)

• Replaces the <think>-only streaming parser with a grammar-driven approach that finds the earliest opener across formats and tracks the matching close tag, preventing remote-streamed Gemma/Qwen reasoning from leaking into the visible answer.

src/services/providers/openAICompatibleStream.ts

Refactor (3) +231 / -225
utils.tsDelegate parsing to utils/messageContent and re-export for back-compat +15/-148

Delegate parsing to utils/messageContent and re-export for back-compat

• Removes the in-component parsing implementation, imports 'parseModelOutput' from 'utils/messageContent.ts', and re-exports parsing APIs/types so existing imports continue to work while store/service layers can depend on util code.

src/components/ChatMessage/utils.ts

chatStore.tsParse once at finalize via parseModelOutput; delete channel-thinking extraction helpers +9/-71

Parse once at finalize via parseModelOutput; delete channel-thinking extraction helpers

• Removes 'extractChannelThinking'/'sliceThinkingBlock' and calls 'parseModelOutput(streamingMessage, streamingReasoningContent)' in 'finalizeStreamingMessage', ensuring the stored assistant answer is clean by construction and consistent with render-time parsing.

src/stores/chatStore.ts

messageContent.tsAdd single-source grammars + move parseModelOutput/parseThinkingContent into util layer +207/-6

Add single-source grammars + move parseModelOutput/parseThinkingContent into util layer

• Introduces 'REASONING_DELIMITERS' and 'TOOL_CALL_OPENERS'/'TOOL_CALL_CLOSERS' as single sources of truth. Derives tool-call stripping regexes from the grammar (including unclosed-EOS stripping), and adds a typed 'parseModelOutput' that guarantees the returned 'answer' is free of reasoning/control/tool-call markup.

src/utils/messageContent.ts

Tests (15) +421 / -2
reasoningPipeline.test.tsxAdd end-to-end integration test for reasoning + tool-call leak prevention +123/-0

Add end-to-end integration test for reasoning + tool-call leak prevention

• Introduces a real-seams integration test driving chatStore streaming→finalize and ChatMessage render. Verifies reasoning capture + clean answers for each REASONING_DELIMITERS format in both local (inline) and remote (ThinkTagParser split) flows, plus asserts tool-call markup never reaches storage or UI.

tests/integration/chat/reasoningPipeline.test.tsx

ChatMessage.test.tsxAdjust messageContent mock to preserve actual exports +1/-0

Adjust messageContent mock to preserve actual exports

• Updates the jest mock to spread 'requireActual', keeping newly-added utilities/exports available while still overriding stripControlTokens for predictable rendering tests.

tests/rntl/components/ChatMessage.test.tsx

ChatMessageAccordionPersistence.test.tsxAdjust messageContent mock to preserve actual exports +1/-0

Adjust messageContent mock to preserve actual exports

• Same mock fix as other RNTL suites so tests continue to work with new exports from messageContent.

tests/rntl/components/ChatMessageAccordionPersistence.test.tsx

ChatMessageToolCallLeak.test.tsxAdd render regression for tool-call markup leak +29/-0

Add render regression for tool-call markup leak

• Adds a UI regression test asserting raw '<tool_call>/<function=...>/<parameter=...>' markup never renders as visible text, for both separate reasoningContent and inline '<think>' scenarios.

tests/rntl/components/ChatMessageToolCallLeak.test.tsx

ChatMessageTools.test.tsxAdd render assertions for OD14 tool-call thinking visibility +39/-0

Add render assertions for OD14 tool-call thinking visibility

• Adds two render tests verifying the pre-tool-call thinking text is actually shown on screen for tool-call messages when thinking is carried via 'message.reasoningContent' or inline '<think>'.

tests/rntl/components/ChatMessageTools.test.tsx

MessageAttachmentsAudio.test.tsxAdjust messageContent mock to preserve actual exports +1/-0

Adjust messageContent mock to preserve actual exports

• Ensures newly added exports from messageContent remain available under the mock used by audio attachment component tests.

tests/rntl/components/MessageAttachmentsAudio.test.tsx

ToolAccordionStreaming.test.tsxAdjust messageContent mock to preserve actual exports +1/-0

Adjust messageContent mock to preserve actual exports

• Ensures the mock doesn’t hide newly added exports required by code paths under test.

tests/rntl/components/ToolAccordionStreaming.test.tsx

turnSpeech.test.tsAdjust core messageContent mock to preserve actual exports +1/-0

Adjust core messageContent mock to preserve actual exports

• Updates the mock of '@offgrid/core/utils/messageContent' to spread requireActual so the module’s expanded surface remains available during unit tests.

tests/unit/audio/turnSpeech.test.ts

parseModelOutput.contract.test.tsAdd contract: parseModelOutput answer is markup-free +30/-0

Add contract: parseModelOutput answer is markup-free

• Adds a contract test asserting 'parseModelOutput().answer' never contains reasoning or tool-call markup across supported formats, and that reasoning-only messages do not duplicate reasoning into answer.

tests/unit/components/ChatMessage/parseModelOutput.contract.test.ts

llmHelpers.branches.test.tsUpdate Gemma4 completion params expectation to reasoning_format:auto +4/-2

Update Gemma4 completion params expectation to reasoning_format:auto

• Updates the branch test to reflect the new native-first behavior for Gemma4 when thinking is enabled.

tests/unit/services/llmHelpers.branches.test.ts

llmToolGeneration.test.tsTest that Gemma4 tool generation uses reasoning_format:auto +14/-0

Test that Gemma4 tool generation uses reasoning_format:auto

• Adds an assertion that 'generateWithToolsImpl' passes 'reasoning_format:'auto'' when Gemma4 + thinking is enabled, ensuring llama.cpp gets the chance to parse natively.

tests/unit/services/llmToolGeneration.test.ts

openAICompatibleStream.test.tsExtend ThinkTagParser tests for Gemma/Qwen channel reasoning formats +33/-0

Extend ThinkTagParser tests for Gemma/Qwen channel reasoning formats

• Adds unit tests verifying the streaming parser routes Gemma and Qwen channel reasoning to the reasoning callback (not visible tokens), including a chunk-split opener scenario.

tests/unit/services/providers/openAICompatibleStream.test.ts

reasoningGrammar.contract.test.tsAdd contract: reasoning grammar is single-source across finalize + streaming parsers +69/-0

Add contract: reasoning grammar is single-source across finalize + streaming parsers

• Introduces a contract that iterates 'REASONING_DELIMITERS' to ensure both 'parseThinkingContent' and 'ThinkTagParser' agree for full strings and char-by-char streaming, preventing DR1-style drift.

tests/unit/utils/reasoningGrammar.contract.test.ts

toolCallGrammar.contract.test.tsAdd contract: tool-call grammar is single-source across stripper + stream filter +73/-0

Add contract: tool-call grammar is single-source across stripper + stream filter

• Adds a contract test iterating the full opener×closer matrix from 'TOOL_CALL_OPENERS'/'CLOSERS' to ensure both 'stripControlTokens' and 'ToolCallTokenFilter' suppress the same tool-call markup, including DR7 colon-opener and unclosed-at-EOS cases.

tests/unit/utils/toolCallGrammar.contract.test.ts

factories.tsExtend message factory to support reasoningContent +2/-0

Extend message factory to support reasoningContent

• Adds 'reasoningContent' to the test message factory to build assistant messages that carry reasoning via a separate channel (needed for OD14 and leak regressions).

tests/utils/factories.ts

Documentation (1) +65 / -0
GAPS_BACKLOG.mdDocument repo hygiene audit + parse-once refactor progress and shipping gate +65/-0

Document repo hygiene audit + parse-once refactor progress and shipping gate

• Adds a detailed SOLID/DRY audit and records parse-once refactor steps completed on this branch, including the explicit on-device verification requirement for the Gemma native-first flip.

docs/GAPS_BACKLOG.md

@qodo-code-review

qodo-code-review Bot commented Jul 9, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 36 rules

Grey Divider


Action required

1. Em dashes in GAPS_BACKLOG ✓ Resolved 📘 Rule violation ✧ Quality
Description
The newly added documentation text uses Unicode em dashes () in prose. This violates the
requirement to avoid em dashes in user-facing documentation text and use ASCII hyphens instead.
Code

docs/GAPS_BACKLOG.md[215]

+| SO1 | src/screens/ModelsScreen/TextModelsTab.tsx:143 handleRetryDownload | BLOCKING | Renderer re-implements download retry (Platform.OS branch, store mutation, mmproj, polling) — CLAUDE.md L100 says this moved to ModelDownloadService. Delete; delegate to modelDownloadService.retry() like useDownloadManager:278. |
Relevance

⭐⭐⭐ High

Team previously accepted replacing em dashes in docs (GAPS_BACKLOG + plans docs) for style
compliance.

PR-#468
PR-#503

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1570610 forbids em dashes in user-facing text such as documentation. The added
lines in docs/GAPS_BACKLOG.md contain multiple  characters used as punctuation in prose (e.g.,
line 215).

Rule 1570610: Disallow em dashes in user-facing text; use hyphens instead
docs/GAPS_BACKLOG.md[215-215]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The updated documentation includes Unicode em dashes (`—`) in user-facing prose, which is disallowed.

## Issue Context
This is in `docs/GAPS_BACKLOG.md` within the newly added audit/refactor notes section.

## Fix Focus Areas
- docs/GAPS_BACKLOG.md[215-270]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. stands as in docs ✓ Resolved 📘 Rule violation ✧ Quality
Description
The updated documentation includes the filler phrase stands as, which is disallowed in
user-visible English text resources. This can violate editorial standards and should be rephrased.
Code

docs/GAPS_BACKLOG.md[269]

+  - If it fires → native 'auto' does NOT cover Gemma in this llama.rn build → keep the hand-parser; the grammar work stands as the fallback. (Relates to OD13: reasoning_format vs actual channel-format mismatch — 'auto' may also fix OD13.)
Relevance

⭐⭐ Medium

No historical evidence found for banning/rephrasing the specific filler phrase “stands as” in docs.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1570659 bans the case-insensitive substring stands as in user-visible
Markdown/doc text. The new text includes stands as the fallback, triggering the rule.

Rule 1570659: Disallow specific filler phrases in user-visible English text resources
docs/GAPS_BACKLOG.md[268-270]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`docs/GAPS_BACKLOG.md` contains the banned filler substring `stands as` in newly added user-visible documentation text.

## Issue Context
The compliance rule forbids certain filler phrases in English text resources including Markdown docs.

## Fix Focus Areas
- docs/GAPS_BACKLOG.md[268-270]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Core util imports UI type ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
src/utils/messageContent.ts now imports ParsedContent from src/components/ChatMessage/types,
reintroducing a utils→component dependency that this refactor explicitly aimed to remove. This makes
messageContent harder to reuse from service/store layers and increases the chance of future
circular-dependency or packaging issues.
Code

src/utils/messageContent.ts[1]

+import type { ParsedContent } from '../components/ChatMessage/types';
Relevance

⭐⭐ Medium

No clear prior reviews found enforcing utils→components type-dependency bans; only general
refactor/SoC patterns.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
messageContent.ts directly imports a type from the ChatMessage component module, while services
(e.g., llmToolGeneration) import from messageContent.ts; this shows the dependency now points from
service/core code into UI code (even if type-only).

src/utils/messageContent.ts[1-23]
src/services/llmToolGeneration.ts[7-13]
src/components/ChatMessage/types.ts[21-26]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`src/utils/messageContent.ts` (a low-level/shared utility) imports `ParsedContent` from `src/components/ChatMessage/types`, creating a dependency from core utils back into a UI component module. This contradicts the PR’s “move parsers down to utils so store/service layers can import without backwards component dependency” goal.

### Issue Context
This is currently a type-only import, but it still:
- encodes an undesirable dependency direction in the codebase,
- makes future refactors riskier (easy to accidentally turn into a runtime dependency),
- can complicate extracting/reusing `messageContent` in other packages/targets.

### Fix Focus Areas
- src/utils/messageContent.ts[1-1]
- src/components/ChatMessage/types.ts[21-26]

### Suggested fix
- Move `ParsedContent` (or a new equivalent type) into a neutral/shared location (e.g. `src/types/messageContent.ts` or `src/utils/messageContentTypes.ts`).
- Update both:
 - `src/utils/messageContent.ts` to import the shared type, and
 - `src/components/ChatMessage/types.ts` to import/re-export it (or define ChatMessage-specific aliases if needed).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

4. Stale comment contradicts refactor ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
A comment in src/utils/messageContent.ts claims parseThinkingContent positional parsing “stays in
ChatMessage/utils.ts”, but parseThinkingContent now lives in messageContent.ts. This is misleading
documentation that can cause future changes to be applied in the wrong place.
Code

↗ src/utils/messageContent.ts

  result = result.replace(/<([\w:-]*(?:tool_call|invoke|function_call)[\w:-]*)[\s\S]*?<\/\1>/gi, '');
Relevance

⭐⭐⭐ High

Team has accepted fixing misleading/outdated comments/JSDoc to match code after refactors.

PR-#181
PR-#168

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The comment explicitly says parsing stays in ChatMessage/utils.ts, while the same file now defines
parseThinkingContent (confirming the comment is outdated).

src/utils/messageContent.ts[66-82]
src/utils/messageContent.ts[182-205]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`src/utils/messageContent.ts` contains a comment stating that `parseThinkingContent` positional parsing “stays in ChatMessage/utils.ts”, which is no longer true after this refactor. This misleads future maintainers.

### Issue Context
`parseThinkingContent` was moved into `src/utils/messageContent.ts` in this PR.

### Fix Focus Areas
- src/utils/messageContent.ts[66-82]
- src/utils/messageContent.ts[182-205]

### Suggested fix
- Update or remove the stale comment so it accurately reflects the current ownership/location of `parseThinkingContent`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread docs/GAPS_BACKLOG.md Outdated
Comment thread docs/GAPS_BACKLOG.md Outdated
Comment thread src/utils/messageContent.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (6)
src/utils/messageContent.ts (1)

42-61: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

parseThinkingContent hardcodes delimiters instead of deriving from REASONING_DELIMITERS.

The header comment states both parsers "derive the reasoning-vs-answer split from THIS set," but parseThinkingContent uses independent hardcoded regexes (Lines 194-195, 217-218, 256-257) rather than iterating REASONING_DELIMITERS. This is the same drift class the grammar is meant to eliminate; a delimiter added to the array won't be honored here unless the contract test catches it. Consider deriving the match logic from the array so the constant is the actual source of truth.

Also applies to: 191-218

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/messageContent.ts` around lines 42 - 61, `parseThinkingContent` is
duplicating the reasoning delimiter grammar instead of using
`REASONING_DELIMITERS`, which can cause drift when new formats are added. Update
`parseThinkingContent` to derive its open/close matching logic from the
`REASONING_DELIMITERS` array rather than hardcoded regex branches, so the same
source of truth drives all delimiter parsing. Keep the existing behavior intact
while centralizing the delimiter lookup around `REASONING_DELIMITERS` and the
`ReasoningDelimiter` contract.
src/services/providers/openAICompatibleStream.ts (1)

105-116: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

partialSuffix/maxPartialSuffix are duplicated verbatim in llmToolGeneration.ts's ToolCallTokenFilter.

Both classes need identical "longest suffix that could still become a tag" logic. Since this PR's whole premise is centralizing grammar primitives to prevent drift, consider hoisting these two pure functions into messageContent.ts alongside REASONING_DELIMITERS/TOOL_CALL_OPENERS so both consumers share one implementation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/providers/openAICompatibleStream.ts` around lines 105 - 116, The
suffix-matching logic in openAICompatibleStream's partialSuffix/maxPartialSuffix
is duplicated in ToolCallTokenFilter, so consolidate the shared "longest suffix
that could still become a tag" behavior into messageContent.ts with
REASONING_DELIMITERS/TOOL_CALL_OPENERS and have both openAICompatibleStream and
llmToolGeneration import and use the same pure helpers. Keep the helper names or
equivalent unique symbols easy to trace from partialSuffix, maxPartialSuffix,
and ToolCallTokenFilter, and remove the duplicate local implementations so the
grammar primitive stays centralized.
__tests__/rntl/components/ChatMessageToolCallLeak.test.tsx (1)

14-16: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unnecessary as any casts.

MessageFactoryOptions already declares content, reasoningContent, and toolCalls matching the shapes used here, so as any should no longer be needed and just suppresses future type-checking on this call.

♻️ Proposed fix
-    const m = createMessage({ role: 'assistant', content: rawBlock,
-      reasoningContent: 'The user wants to know about Achilles.',
-      toolCalls: [{ id: 't1', name: 'search_knowledge_base', arguments: '{"query":"Achilles of Troy"}' }] } as any);
+    const m = createMessage({ role: 'assistant', content: rawBlock,
+      reasoningContent: 'The user wants to know about Achilles.',
+      toolCalls: [{ id: 't1', name: 'search_knowledge_base', arguments: '{"query":"Achilles of Troy"}' }] });

Also applies to: 23-25

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@__tests__/rntl/components/ChatMessageToolCallLeak.test.tsx` around lines 14 -
16, The createMessage call in ChatMessageToolCallLeak.test.tsx is using an
unnecessary as any cast even though MessageFactoryOptions already matches the
provided shape. Remove the cast at the createMessage invocation(s) and keep the
object typed normally so TypeScript continues validating content,
reasoningContent, and toolCalls.
__tests__/integration/chat/reasoningPipeline.test.tsx (1)

66-68: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Minor: duplicated regex-escape logic.

The open.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&') escape is repeated verbatim in both the LOCAL and REMOTE test bodies. Consider extracting a small escapeForRegex helper at module scope to avoid drift if the escape set ever needs updating.

Also applies to: 94-97

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@__tests__/integration/chat/reasoningPipeline.test.tsx` around lines 66 - 68,
The regex-escape logic is duplicated in the reasoning pipeline tests, so extract
the repeated open.trim().replace(...) pattern into a small module-scope helper
such as escapeForRegex and reuse it in both the LOCAL and REMOTE test bodies.
Update the assertions in reasoningPipeline.test.tsx to call that helper instead
of inlining the escape expression, keeping the behavior unchanged while
centralizing the escape set.
docs/GAPS_BACKLOG.md (2)

236-236: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Em dashes violate the docs style guide.

Several new lines use em dashes ("—" at lines 236, 252, 254 (×2), 259), which the brand tone guide explicitly disallows.

As per coding guidelines, {docs/**/*.md,...}: "Any change to website copy, essays, docs text, UI strings, or marketing content must follow docs/brand_tone_voice.md and its copy standards (proof-first, no exclamation marks, no em dashes, no forbidden words, no curly quotes, etc.)."

Also applies to: 252-252, 254-254, 259-259

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/GAPS_BACKLOG.md` at line 236, The docs text in GAPS_BACKLOG.md uses em
dashes, which violates the brand tone/copy standards. Update the affected prose
in the documented sections (including the “Test quality (§D)” entry and the
other flagged lines) to remove em dashes and replace them with compliant
punctuation while preserving the meaning and tone. Check nearby markdown content
for similar copy in the same document and ensure it follows
docs/brand_tone_voice.md.

Source: Coding guidelines


224-234: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Stale audit rows contradict the update section below.

DR1 (line 227) and DR7 (line 233) are logged as "DRIFTED (live)" in the DRY table, but the "UPDATE 2026-07-09" section right below (lines 259-264, same date) states both were resolved by this PR (Step B for DR1, Step C for DR7). Leaving the table rows unmarked risks a future reader treating already-fixed drift as still open.

📝 Suggested note
-| DR1 | chatStore.extractChannelThinking + ChatMessage/utils.parseThinkingContent + providers/openAICompatibleStream.ThinkTagParser | DRIFTED (live) | 3 thinking parsers; the remote STREAM parser only knows <think>, omits channel formats → Gemma4/Qwen-channel model over remote endpoint leaks raw <\|channel>thought. Ties to OD16. One shared splitReasoning() in messageContent (already owns REASONING_TEMPLATE_MARKERS). |
+| DR1 | chatStore.extractChannelThinking + ChatMessage/utils.parseThinkingContent + providers/openAICompatibleStream.ThinkTagParser | RESOLVED (see UPDATE 2026-07-09 Step B) | 3 thinking parsers; the remote STREAM parser only knows <think>, omits channel formats → Gemma4/Qwen-channel model over remote endpoint leaks raw <\|channel>thought. Ties to OD16. |

Also applies to: 259-270

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/GAPS_BACKLOG.md` around lines 224 - 234, Update the DRY audit table so
the DR1 and DR7 rows in the backlog no longer contradict the “UPDATE 2026-07-09”
section. In the markdown near the DR1 and DR7 entries, change their
verdict/status to reflect that the issues were resolved by this PR, and keep the
narrative consistent with the update notes that reference Step B for DR1 and
Step C for DR7. Use the existing section labels and row identifiers to locate
the stale entries and ensure the table and update section tell the same story.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@__tests__/unit/components/ChatMessage/parseModelOutput.contract.test.ts`:
- Around line 6-23: The contract test’s MARKUP regex is duplicating and drifting
from the shared markup grammar. Update the parseModelOutput test to build the
matcher from the exported constants in messageContent.ts, using
REASONING_DELIMITERS, TOOL_CALL_OPENERS, TOOL_CALL_CLOSERS, and the same
stripping rules used by stripControlTokens. Include all known leak vectors such
as closing tags, GEMMA4_THINK_OPEN/CLOSE, <channel|>, and the bare
namespace:tool_call pattern so the test stays aligned with future grammar
changes.

---

Nitpick comments:
In `@__tests__/integration/chat/reasoningPipeline.test.tsx`:
- Around line 66-68: The regex-escape logic is duplicated in the reasoning
pipeline tests, so extract the repeated open.trim().replace(...) pattern into a
small module-scope helper such as escapeForRegex and reuse it in both the LOCAL
and REMOTE test bodies. Update the assertions in reasoningPipeline.test.tsx to
call that helper instead of inlining the escape expression, keeping the behavior
unchanged while centralizing the escape set.

In `@__tests__/rntl/components/ChatMessageToolCallLeak.test.tsx`:
- Around line 14-16: The createMessage call in ChatMessageToolCallLeak.test.tsx
is using an unnecessary as any cast even though MessageFactoryOptions already
matches the provided shape. Remove the cast at the createMessage invocation(s)
and keep the object typed normally so TypeScript continues validating content,
reasoningContent, and toolCalls.

In `@docs/GAPS_BACKLOG.md`:
- Line 236: The docs text in GAPS_BACKLOG.md uses em dashes, which violates the
brand tone/copy standards. Update the affected prose in the documented sections
(including the “Test quality (§D)” entry and the other flagged lines) to remove
em dashes and replace them with compliant punctuation while preserving the
meaning and tone. Check nearby markdown content for similar copy in the same
document and ensure it follows docs/brand_tone_voice.md.
- Around line 224-234: Update the DRY audit table so the DR1 and DR7 rows in the
backlog no longer contradict the “UPDATE 2026-07-09” section. In the markdown
near the DR1 and DR7 entries, change their verdict/status to reflect that the
issues were resolved by this PR, and keep the narrative consistent with the
update notes that reference Step B for DR1 and Step C for DR7. Use the existing
section labels and row identifiers to locate the stale entries and ensure the
table and update section tell the same story.

In `@src/services/providers/openAICompatibleStream.ts`:
- Around line 105-116: The suffix-matching logic in openAICompatibleStream's
partialSuffix/maxPartialSuffix is duplicated in ToolCallTokenFilter, so
consolidate the shared "longest suffix that could still become a tag" behavior
into messageContent.ts with REASONING_DELIMITERS/TOOL_CALL_OPENERS and have both
openAICompatibleStream and llmToolGeneration import and use the same pure
helpers. Keep the helper names or equivalent unique symbols easy to trace from
partialSuffix, maxPartialSuffix, and ToolCallTokenFilter, and remove the
duplicate local implementations so the grammar primitive stays centralized.

In `@src/utils/messageContent.ts`:
- Around line 42-61: `parseThinkingContent` is duplicating the reasoning
delimiter grammar instead of using `REASONING_DELIMITERS`, which can cause drift
when new formats are added. Update `parseThinkingContent` to derive its
open/close matching logic from the `REASONING_DELIMITERS` array rather than
hardcoded regex branches, so the same source of truth drives all delimiter
parsing. Keep the existing behavior intact while centralizing the delimiter
lookup around `REASONING_DELIMITERS` and the `ReasoningDelimiter` contract.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: dcaa3f7c-369c-421a-8d8e-b8f008f00316

📥 Commits

Reviewing files that changed from the base of the PR and between f30dfe2 and 5af9caf.

📒 Files selected for processing (24)
  • __tests__/integration/chat/reasoningPipeline.test.tsx
  • __tests__/rntl/components/ChatMessage.test.tsx
  • __tests__/rntl/components/ChatMessageAccordionPersistence.test.tsx
  • __tests__/rntl/components/ChatMessageToolCallLeak.test.tsx
  • __tests__/rntl/components/ChatMessageTools.test.tsx
  • __tests__/rntl/components/MessageAttachmentsAudio.test.tsx
  • __tests__/rntl/components/ToolAccordionStreaming.test.tsx
  • __tests__/unit/audio/turnSpeech.test.ts
  • __tests__/unit/components/ChatMessage/parseModelOutput.contract.test.ts
  • __tests__/unit/services/llmHelpers.branches.test.ts
  • __tests__/unit/services/llmToolGeneration.test.ts
  • __tests__/unit/services/providers/openAICompatibleStream.test.ts
  • __tests__/unit/utils/reasoningGrammar.contract.test.ts
  • __tests__/unit/utils/toolCallGrammar.contract.test.ts
  • __tests__/utils/factories.ts
  • docs/GAPS_BACKLOG.md
  • src/components/ChatMessage/index.tsx
  • src/components/ChatMessage/utils.ts
  • src/services/generationToolLoop.ts
  • src/services/llmHelpers.ts
  • src/services/llmToolGeneration.ts
  • src/services/providers/openAICompatibleStream.ts
  • src/stores/chatStore.ts
  • src/utils/messageContent.ts

Comment thread __tests__/unit/components/ChatMessage/parseModelOutput.contract.test.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/services/memoryBudget.ts (1)

60-82: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Solid platform-aware floor logic, well documented.

Logic and rationale are clear and match the downstream makeRoomFor usage. One minor typing nit: Plat is defined as 'ios' | 'android' | string, but unioning literal types with string widens the whole type to plain string, so the literal narrowing provides no real benefit.

♻️ Optional typing tightening
-type Plat = 'ios' | 'android' | string;
+type Plat = 'ios' | 'android' | 'windows' | 'macos' | 'web';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/memoryBudget.ts` around lines 60 - 82, The `Plat` type in
`memoryBudget.ts` is effectively widened to `string`, so the `'ios'`/`'android'`
literals provide no narrowing benefit. Tighten the type used by
`overrideSurvivalFloorMB` by changing `Plat` to a real platform union or
importing the existing platform type from `Platform.OS`, then keep the function
signature aligned so the platform-specific branch remains properly typed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/services/memoryBudget.ts`:
- Around line 60-82: The `Plat` type in `memoryBudget.ts` is effectively widened
to `string`, so the `'ios'`/`'android'` literals provide no narrowing benefit.
Tighten the type used by `overrideSurvivalFloorMB` by changing `Plat` to a real
platform union or importing the existing platform type from `Platform.OS`, then
keep the function signature aligned so the platform-specific branch remains
properly typed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 67cccb84-6a84-46d8-bd03-3e316a780de4

📥 Commits

Reviewing files that changed from the base of the PR and between fc2d7ef and 143f2b7.

📒 Files selected for processing (8)
  • __tests__/unit/services/activeModelService.loaders.branches.test.ts
  • __tests__/unit/services/activeModelService.loaders.test.ts
  • __tests__/unit/services/modelResidency.test.ts
  • docs/GAPS_BACKLOG.md
  • src/components/ChatMessage/styles.ts
  • src/services/activeModelService/loaders.ts
  • src/services/memoryBudget.ts
  • src/services/modelResidency/index.ts
✅ Files skipped from review due to trivial changes (2)
  • tests/unit/services/activeModelService.loaders.branches.test.ts
  • docs/GAPS_BACKLOG.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/services/modelResidency/index.ts

@alichherawalla

Copy link
Copy Markdown
Collaborator Author

Self-audit (review loop pass 2)

Mechanical gates

  • prettier (new files) · eslint 0 errors · tsc clean · full suite 7,741 passing · android production bundle clean.

Review signal — all triaged & addressed (ff6efed)

  • ✅ Qodo: em dashes in GAPS → removed. ✅ Qodo: "stands as" filler → rephrased.
  • ✅ Qodo: messageContent imported ParsedContent from the component (utils→UI dep) → type moved to the util that owns it; component re-exports. Correct direction now.
  • ✅ CodeRabbit: contract-test MARKUP regex hand-rolled/driftable → derived from the exported grammar constants.
  • ✅ CodeRabbit nitpick: partialSuffix/maxPartialSuffix duplicated in both parsers → consolidated into shared partialTagSuffix/maxPartialTagSuffix.
  • ⏸️ CodeRabbit nitpick: regex-escape dup in tests → deferred (test-only, low value).

SOLID / DRY

Single-source grammars (REASONING_DELIMITERS, TOOL_CALL_OPENERS/CLOSERS) now also back the contract test and the shared suffix primitive — no parser/matcher can drift from the grammar. ParsedContent owned by its producer. No Platform.OS-mechanism branch in a caller (the memory floor is a data constant overrideSurvivalFloorMB).

Tests — real seams

Co-residence OOM: red-first cross-engine tests both directions (LiteRT↔GGUF), assert the previous engine is unloaded. Platform floor: Android loads ~3.7GB / refuses 5.2GB / iOS unchanged. Parse-once: integration through real store→finalize→render, no markup leak.

Honesty — status is code / wired / verified

  • Verified (tests): parse-once (behavior-neutral, 7,741 suite), co-residence unload, mutual-exclusion, whisper eager-load removal, platform-floor logic.
  • NOT verified — MUST device-test before merge/release (hygiene §H):
    • Platform-aware memory floor — the 700MB Android margin needs on-device tuning; [MEM-SM] logs the real numbers.
    • Native-first (Gemma auto) — run a Gemma tool-call turn, check [GEMMA-FALLBACK] never fires before deleting the hand-parser.
    • iOS collapsed thinking-box width — screenshot (RNTL can't measure layout).
  • Pre-existing, out of scope (logged in GAPS): mid-chat model-switch stuck-overlay/stale-state; TTS not streaming under memory pressure (fits-gated speak-after).

Verdict: code + wired; NOT yet verified. Do not merge until the three device checks above pass. Fix A (ZRAM swap credit) reverted — it caused an OOM.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
docs/GAPS_BACKLOG.md (2)

236-245: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Table missing surrounding blank lines (MD058).

Markdownlint flags the "Test quality (§D)" table at line 237 for missing blank lines around it, which can break table rendering in some Markdown renderers.

📝 Suggested fix
-### Test quality (§D) - 371 files, ~13 with a genuinely weak top-tier block
+### Test quality (§D) - 371 files, ~13 with a genuinely weak top-tier block
+
 | # | File | Verdict | Fix |
 |---|------|---------|-----|
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/GAPS_BACKLOG.md` around lines 236 - 245, The “Test quality (§D)”
markdown table is missing the required blank lines around it, which can affect
rendering. Update the surrounding markdown in GAPS_BACKLOG.md so the table is
separated from adjacent paragraphs/details content by empty lines, while keeping
the table rows and headers unchanged.

Source: Linters/SAST tools


264-264: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

[GEMMA-FALLBACK] parsed as a broken Markdown reference link (MD052).

The bracketed log-tag text is being interpreted as a reference-style link with no matching definition. Wrapping it in backticks avoids the false link syntax and better signals it's a literal log tag.

📝 Suggested fix
-- Native-first: buildThinkingCompletionParams Gemma4 reasoning_format 'none'→'auto' so llama.cpp parses Gemma channel + tool calls NATIVELY (resolveToolCalls/finalize already fall back to hand-parse only when native is empty, so behavior-neutral if 'auto' doesn't recognise it). Added [ToolLoop][GEMMA-FALLBACK] log when the hand-parser fires.
+- Native-first: buildThinkingCompletionParams Gemma4 reasoning_format 'none'→'auto' so llama.cpp parses Gemma channel + tool calls NATIVELY (resolveToolCalls/finalize already fall back to hand-parse only when native is empty, so behavior-neutral if 'auto' doesn't recognise it). Added `[ToolLoop][GEMMA-FALLBACK]` log when the hand-parser fires.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/GAPS_BACKLOG.md` at line 264, The backlog note in GAPS_BACKLOG.md is
triggering MD052 because the literal log tag [GEMMA-FALLBACK] is being parsed as
a Markdown reference link; update the text so the tag is rendered as code or
otherwise clearly literal, keeping the reference to
buildThinkingCompletionParams, resolveToolCalls, and finalize unchanged.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@docs/GAPS_BACKLOG.md`:
- Around line 236-245: The “Test quality (§D)” markdown table is missing the
required blank lines around it, which can affect rendering. Update the
surrounding markdown in GAPS_BACKLOG.md so the table is separated from adjacent
paragraphs/details content by empty lines, while keeping the table rows and
headers unchanged.
- Line 264: The backlog note in GAPS_BACKLOG.md is triggering MD052 because the
literal log tag [GEMMA-FALLBACK] is being parsed as a Markdown reference link;
update the text so the tag is rendered as code or otherwise clearly literal,
keeping the reference to buildThinkingCompletionParams, resolveToolCalls, and
finalize unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9a4123a3-3fec-4292-97b8-bd562118b5f6

📥 Commits

Reviewing files that changed from the base of the PR and between 143f2b7 and ff6efed.

📒 Files selected for processing (6)
  • __tests__/unit/components/ChatMessage/parseModelOutput.contract.test.ts
  • docs/GAPS_BACKLOG.md
  • src/components/ChatMessage/types.ts
  • src/services/llmToolGeneration.ts
  • src/services/providers/openAICompatibleStream.ts
  • src/utils/messageContent.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/services/llmToolGeneration.ts
  • src/services/providers/openAICompatibleStream.ts
  • src/utils/messageContent.ts

@alichherawalla

Copy link
Copy Markdown
Collaborator Author

Addressed the CodeRabbit nitpick (memoryBudget.ts Plat widening) in 6c8c985 — typed overrideSurvivalFloorMB's param as typeof Platform.OS so the === 'android' comparison narrows properly, without touching the shared Plat alias other budget functions use. Gates green (eslint/tsc, 73 residency+budget tests). All review findings now addressed.

Comment on lines +39 to +53
private handleOutsideThink(onToken: (t: string) => void): boolean {
let bestIdx = -1;
let bestOpen = '';
let bestClose = '';
for (const d of REASONING_DELIMITERS) {
const idx = this.buffer.indexOf(d.open);
if (idx !== -1 && (bestIdx === -1 || idx < bestIdx)) {
bestIdx = idx;
bestOpen = d.open;
bestClose = d.close;
}
}
if (bestIdx === -1) {
// No complete opener. Hold back only a suffix that could still become one of the openers.
const partial = maxPartialTagSuffix(this.buffer, REASONING_DELIMITERS.map((d) => d.open));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: ThinkTagParser never flushes trailing partial-opener at stream end

In the generalized ThinkTagParser, handleOutsideThink now holds back the longest suffix of the buffer that is a prefix of ANY reasoning opener (maxPartialTagSuffix over REASONING_DELIMITERS). Since there is no end-of-stream flush of the parser (the only entry point is process(), called per-delta in processDelta, and finalizeStreamingMessage consumes only the accumulated onToken output), a held-back suffix on the final chunk is silently dropped from the visible answer.

This was largely pre-existing for <think>, but the new grammar widens the withheld set: openers like <|channel|>analysis<|message|> and <|channel>thought mean a final chunk ending in <| (previously emitted, since <| is not a prefix of <think>) is now held back and lost. The risk is low (remote answers rarely end mid-token on a tag-like prefix) but real. Consider adding an explicit end()/flush() method that emits any residual buffer, and call it when the stream completes.

Flush residual buffer when the stream ends so a trailing partial-opener is not dropped.:

// Add to ThinkTagParser and call on stream completion (after the last delta):
end(onToken: (t: string) => void, onReasoning: (t: string) => void): void {
  if (this.buffer.length === 0) return;
  // No more data is coming: a held-back partial tag is just text/reasoning now.
  if (this.inThinkBlock) onReasoning(this.buffer);
  else onToken(this.buffer);
  this.buffer = '';
}
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

Comment thread src/screens/ChatScreen/useChatModelActions.ts
Comment on lines +493 to 497
const kind = await resolveTurnKind(deps, { text: messageText, recordedKind });
if (kind === 'image') {
await handleImageGenerationFn(deps, { prompt: userMessage.content, conversationId: targetConversationId, skipUserMessage: true });
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: regenerate no longer falls back to text when image model is gone

In regenerateResponseFn the old guard if (shouldGenerateImage && deps.activeImageModel) was replaced by if (kind === 'image'), dropping the deps.activeImageModel check (src/screens/ChatScreen/useChatGenerationActions.ts:493-497). For a recorded image turn whose image model was later removed/deselected, a resend/edit now routes to handleImageGenerationFn, which alerts "No image model loaded" and returns — it no longer falls through to text generation. Note dispatchGenerationFn KEPT the && deps.activeImageModel guard (line ~424), so the two seams that were unified now diverge in this edge case. This is defensible behavior (a recorded image turn should stay an image turn), but the divergence is subtle and worth an explicit decision. For legacy turns (no recordedKind) there is no regression because shouldRouteToImageGenerationFn already returns false when activeImageModel is falsy.

Was this helpful? React with 👍 / 👎

Comment thread src/services/engines.ts
Comment on lines +97 to +111
export function isModelReady(model: { engine?: string; filePath?: string } | null | undefined): boolean {
if (!model) return false;
return model.engine === 'litert'
? liteRTService.isModelLoaded()
: llmService.isModelLoaded() && llmService.getLoadedModelPath() === model.filePath;
}

/**
* Live capabilities of the ACTIVE text model (remote OR local), read from the running services
* and fed through the one pure rule (deriveEngineCapabilities). The imperative counterpart to the
* pure fn: every caller (generation routing, UI capability flags) uses THIS instead of poking
* llmService / liteRTService directly or branching on engine === 'litert' — so a concrete engine
* service never has to be imported into a screen (DIP). Adding a backend = extend
* deriveEngineCapabilities, not the callers (OCP).
* `thinking` here is CAPABILITY (does the model support it — drives the UI toggle), not "enabled

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Quality: New engines.ts functions have no direct test; some branches uncovered

engines.ts adds several exported functions (isModelReady, activeTextCapabilities, activeLocalTextCapabilities, isLiteRTAvailable, wantsLeadingThinkToken) but there is no dedicated engines test file — they are only exercised indirectly through the hook tests. The repo standard requires 100% branch coverage on new code. Notably the llama Gemma-4 branch of wantsLeadingThinkToken (the llmService.isGemma4Model() && llmService.isThinkingEnabled() path returning true) is never exercised: every test mocks isGemma4Model to false; only the LiteRT-loaded + thinkingEnabled branch is covered. Add direct unit tests for engines.ts covering the remote, litert-loaded, and llama-gemma4 branches of these pure/registry functions so the coverage gate reflects real behavior rather than incidental coverage.

Was this helpful? React with 👍 / 👎

Comment thread src/utils/visionModel.ts
Comment on lines +11 to +17
export const VISION_NAME_PATTERNS: readonly string[] = [
'-vl', 'vl-', ':vl', 'vlm', // common VL naming (qwen3-vl, llava, *-vlm)
'vision', 'llava', 'bakllava', 'moondream', 'cogvlm',
'cogagent', 'fuyu', 'idefics', 'qwen-vl', 'gpt-4-vision',
'gpt-4o', 'claude-3', 'gemini', 'pixtral', 'phi-3.5-vision',
'minicpm-v', 'internvl', 'yi-vl', 'smolvlm', 'llama-3.2-vision',
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: Unified vision patterns broaden local HF classification, risking false positives

DR2 replaces the local 3-keyword vision check (vision/vlm/llava) with the full 24-entry VISION_NAME_PATTERNS via looksLikeVisionModel. That list contains broad, boundary-less substrings ('gpt-4o', 'claude-3', 'gemini', 'vlm', ':vl', 'vl-') that are matched against HuggingFace search result names/ids in getModelType and detectModelType. Community text-only repos commonly embed these tokens in their names (e.g. distillation fine-tunes named '...-gpt-4o-...', or repos containing 'gemini'/'claude-3'). getModelType returns 'Vision' before falling back to 'Text generation', so such text models get classified as Vision and can be hidden from the text list / miscategorized in ModelsScreen.

This is intentional consistency with the remote detector (which already used these patterns), so severity is minor — but the local surface (HF search) is newly exposed to the false-positive risk. Consider narrowing the risky remote-only patterns (gpt-4o, claude-3, gemini) since those hosted models never appear as downloadable HF repos, or anchoring the VL substrings to word boundaries to avoid incidental matches.

Keep cloud-only families (gpt-4o/claude-3/gemini) out of the list applied to HuggingFace search classification.:

// Split remote-only cloud families (never HF-downloadable) from on-device VL families.
export const VISION_NAME_PATTERNS: readonly string[] = [
  '-vl', 'vl-', ':vl', 'vlm',
  'vision', 'llava', 'bakllava', 'moondream', 'cogvlm',
  'cogagent', 'fuyu', 'idefics', 'qwen-vl', 'pixtral',
  'phi-3.5-vision', 'minicpm-v', 'internvl', 'yi-vl',
  'smolvlm', 'llama-3.2-vision',
];
// Cloud-only names used only by the remote capability detector:
export const REMOTE_VISION_NAME_PATTERNS: readonly string[] = [
  ...VISION_NAME_PATTERNS, 'gpt-4-vision', 'gpt-4o', 'claude-3', 'gemini',
];
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

Comment thread src/services/remoteServerManagerUtils.ts
@gitar-bot

gitar-bot Bot commented Jul 10, 2026

Copy link
Copy Markdown
Code Review 👍 Approved with suggestions 2 resolved / 6 findings

Consolidates reasoning and tool-call parsing into a single grammar while implementing platform-aware memory management to prevent OOM crashes. Consider addressing the trailing partial-opener flush in ThinkTagParser, the missing fallback in regenerateResponse, and narrowing the VISION_NAME_PATTERNS to avoid classification false positives.

💡 Edge Case: ThinkTagParser never flushes trailing partial-opener at stream end

📄 src/services/providers/openAICompatibleStream.ts:39-53 📄 src/services/providers/openAICompatibleStream.ts:96-103

In the generalized ThinkTagParser, handleOutsideThink now holds back the longest suffix of the buffer that is a prefix of ANY reasoning opener (maxPartialTagSuffix over REASONING_DELIMITERS). Since there is no end-of-stream flush of the parser (the only entry point is process(), called per-delta in processDelta, and finalizeStreamingMessage consumes only the accumulated onToken output), a held-back suffix on the final chunk is silently dropped from the visible answer.

This was largely pre-existing for <think>, but the new grammar widens the withheld set: openers like <|channel|>analysis<|message|> and <|channel>thought mean a final chunk ending in <| (previously emitted, since <| is not a prefix of <think>) is now held back and lost. The risk is low (remote answers rarely end mid-token on a tag-like prefix) but real. Consider adding an explicit end()/flush() method that emits any residual buffer, and call it when the stream completes.

Flush residual buffer when the stream ends so a trailing partial-opener is not dropped.
// Add to ThinkTagParser and call on stream completion (after the last delta):
end(onToken: (t: string) => void, onReasoning: (t: string) => void): void {
  if (this.buffer.length === 0) return;
  // No more data is coming: a held-back partial tag is just text/reasoning now.
  if (this.inThinkBlock) onReasoning(this.buffer);
  else onToken(this.buffer);
  this.buffer = '';
}
💡 Edge Case: regenerate no longer falls back to text when image model is gone

📄 src/screens/ChatScreen/useChatGenerationActions.ts:493-497 📄 src/screens/ChatScreen/useChatGenerationActions.ts:420-427

In regenerateResponseFn the old guard if (shouldGenerateImage && deps.activeImageModel) was replaced by if (kind === 'image'), dropping the deps.activeImageModel check (src/screens/ChatScreen/useChatGenerationActions.ts:493-497). For a recorded image turn whose image model was later removed/deselected, a resend/edit now routes to handleImageGenerationFn, which alerts "No image model loaded" and returns — it no longer falls through to text generation. Note dispatchGenerationFn KEPT the && deps.activeImageModel guard (line ~424), so the two seams that were unified now diverge in this edge case. This is defensible behavior (a recorded image turn should stay an image turn), but the divergence is subtle and worth an explicit decision. For legacy turns (no recordedKind) there is no regression because shouldRouteToImageGenerationFn already returns false when activeImageModel is falsy.

💡 Quality: New engines.ts functions have no direct test; some branches uncovered

📄 src/services/engines.ts:97-111 📄 tests/unit/hooks/useChatGenerationActions.test.ts

engines.ts adds several exported functions (isModelReady, activeTextCapabilities, activeLocalTextCapabilities, isLiteRTAvailable, wantsLeadingThinkToken) but there is no dedicated engines test file — they are only exercised indirectly through the hook tests. The repo standard requires 100% branch coverage on new code. Notably the llama Gemma-4 branch of wantsLeadingThinkToken (the llmService.isGemma4Model() && llmService.isThinkingEnabled() path returning true) is never exercised: every test mocks isGemma4Model to false; only the LiteRT-loaded + thinkingEnabled branch is covered. Add direct unit tests for engines.ts covering the remote, litert-loaded, and llama-gemma4 branches of these pure/registry functions so the coverage gate reflects real behavior rather than incidental coverage.

💡 Edge Case: Unified vision patterns broaden local HF classification, risking false positives

📄 src/utils/visionModel.ts:11-17 📄 src/screens/ModelsScreen/utils.ts:42-46 📄 src/services/huggingface.ts:178-182

DR2 replaces the local 3-keyword vision check (vision/vlm/llava) with the full 24-entry VISION_NAME_PATTERNS via looksLikeVisionModel. That list contains broad, boundary-less substrings ('gpt-4o', 'claude-3', 'gemini', 'vlm', ':vl', 'vl-') that are matched against HuggingFace search result names/ids in getModelType and detectModelType. Community text-only repos commonly embed these tokens in their names (e.g. distillation fine-tunes named '...-gpt-4o-...', or repos containing 'gemini'/'claude-3'). getModelType returns 'Vision' before falling back to 'Text generation', so such text models get classified as Vision and can be hidden from the text list / miscategorized in ModelsScreen.

This is intentional consistency with the remote detector (which already used these patterns), so severity is minor — but the local surface (HF search) is newly exposed to the false-positive risk. Consider narrowing the risky remote-only patterns (gpt-4o, claude-3, gemini) since those hosted models never appear as downloadable HF repos, or anchoring the VL substrings to word boundaries to avoid incidental matches.

Keep cloud-only families (gpt-4o/claude-3/gemini) out of the list applied to HuggingFace search classification.
// Split remote-only cloud families (never HF-downloadable) from on-device VL families.
export const VISION_NAME_PATTERNS: readonly string[] = [
  '-vl', 'vl-', ':vl', 'vlm',
  'vision', 'llava', 'bakllava', 'moondream', 'cogvlm',
  'cogagent', 'fuyu', 'idefics', 'qwen-vl', 'pixtral',
  'phi-3.5-vision', 'minicpm-v', 'internvl', 'yi-vl',
  'smolvlm', 'llama-3.2-vision',
];
// Cloud-only names used only by the remote capability detector:
export const REMOTE_VISION_NAME_PATTERNS: readonly string[] = [
  ...VISION_NAME_PATTERNS, 'gpt-4-vision', 'gpt-4o', 'claude-3', 'gemini',
];
✅ 2 resolved
Quality: Vision derivation drops the activeModelMmProjPath guard on migration

📄 src/screens/ChatScreen/useChatModelActions.ts:375-385 📄 src/services/engines.ts:56-62
The prior useChatModelStateSync vision effect only set vision from the llama engine when activeModelMmProjPath && llmService.isModelLoaded(). The migrated path via deriveEngineCapabilities computes llama vision purely as i.llama.loaded ? i.llama.vision : false where i.llama.vision = llmService.getMultimodalSupport()?.vision ?? false, dropping the activeModelMmProjPath gate (the effect still lists it in the dependency array, but the value is no longer used in the derivation).

This is likely behavior-neutral in practice (multimodal support should only report vision: true when a projector was actually initialized), so it is not confirmed as a bug. However, if the llama engine can retain multimodal state from a previously-loaded vision model during an in-place model switch, the new derivation could report vision: true for a freshly-selected non-vision model until reload. Worth a quick check that getMultimodalSupport().vision is always false without a projector, or restore the mmproj gate to preserve the exact prior contract.

Bug: Imports reference missing module utils/remoteCapabilityDetect

📄 src/services/remoteServerManagerUtils.ts:60-62 📄 src/stores/remoteServerHelpers.ts:16-19
The delta moves the pure capability detectors out of remoteServerManagerUtils.ts and re-exports them from ../utils/remoteCapabilityDetect, and remoteServerHelpers.ts now imports detectVisionCapability/detectToolCallingCapability from the same path. However, no such module exists in the repo: src/utils/remoteCapabilityDetect.ts (and any .tsx/index variant) is not present, and grepping for export function detectVisionCapability / export function detectToolCallingCapability returns zero definitions anywhere in the tree.

Because the original implementations were deleted from remoteServerManagerUtils.ts in this same commit, both symbols are now completely undefined. Every module that resolves these imports (remoteServerManagerUtils.ts re-export, remoteServerHelpers.ts) will fail module resolution — a TypeScript compile error and a runtime import failure. Remote model vision/tool-calling capability detection is broken, and anything importing these modules (remote server discovery/management) will crash on load.

This directly contradicts the PR's "tsc clean" gate claim. Fix by actually adding src/utils/remoteCapabilityDetect.ts exporting both functions (moving the deleted logic there, with looksLikeVisionModel imported from ../utils/visionModel), or point the imports back at a module that actually defines them.

🤖 Prompt for agents
Code Review: Consolidates reasoning and tool-call parsing into a single grammar while implementing platform-aware memory management to prevent OOM crashes. Consider addressing the trailing partial-opener flush in ThinkTagParser, the missing fallback in regenerateResponse, and narrowing the VISION_NAME_PATTERNS to avoid classification false positives.

1. 💡 Edge Case: ThinkTagParser never flushes trailing partial-opener at stream end
   Files: src/services/providers/openAICompatibleStream.ts:39-53, src/services/providers/openAICompatibleStream.ts:96-103

   In the generalized `ThinkTagParser`, `handleOutsideThink` now holds back the longest suffix of the buffer that is a prefix of ANY reasoning opener (`maxPartialTagSuffix` over `REASONING_DELIMITERS`). Since there is no end-of-stream flush of the parser (the only entry point is `process()`, called per-delta in `processDelta`, and `finalizeStreamingMessage` consumes only the accumulated `onToken` output), a held-back suffix on the final chunk is silently dropped from the visible answer.
   
   This was largely pre-existing for `<think>`, but the new grammar widens the withheld set: openers like `<|channel|>analysis<|message|>` and `<|channel>thought` mean a final chunk ending in `<|` (previously emitted, since `<|` is not a prefix of `<think>`) is now held back and lost. The risk is low (remote answers rarely end mid-token on a tag-like prefix) but real. Consider adding an explicit `end()`/`flush()` method that emits any residual buffer, and call it when the stream completes.

   Fix (Flush residual buffer when the stream ends so a trailing partial-opener is not dropped.):
   // Add to ThinkTagParser and call on stream completion (after the last delta):
   end(onToken: (t: string) => void, onReasoning: (t: string) => void): void {
     if (this.buffer.length === 0) return;
     // No more data is coming: a held-back partial tag is just text/reasoning now.
     if (this.inThinkBlock) onReasoning(this.buffer);
     else onToken(this.buffer);
     this.buffer = '';
   }

2. 💡 Edge Case: regenerate no longer falls back to text when image model is gone
   Files: src/screens/ChatScreen/useChatGenerationActions.ts:493-497, src/screens/ChatScreen/useChatGenerationActions.ts:420-427

   In `regenerateResponseFn` the old guard `if (shouldGenerateImage && deps.activeImageModel)` was replaced by `if (kind === 'image')`, dropping the `deps.activeImageModel` check (src/screens/ChatScreen/useChatGenerationActions.ts:493-497). For a recorded image turn whose image model was later removed/deselected, a resend/edit now routes to `handleImageGenerationFn`, which alerts "No image model loaded" and returns — it no longer falls through to text generation. Note `dispatchGenerationFn` KEPT the `&& deps.activeImageModel` guard (line ~424), so the two seams that were unified now diverge in this edge case. This is defensible behavior (a recorded image turn should stay an image turn), but the divergence is subtle and worth an explicit decision. For legacy turns (no recordedKind) there is no regression because `shouldRouteToImageGenerationFn` already returns false when `activeImageModel` is falsy.

3. 💡 Quality: New engines.ts functions have no direct test; some branches uncovered
   Files: src/services/engines.ts:97-111, __tests__/unit/hooks/useChatGenerationActions.test.ts

   engines.ts adds several exported functions (`isModelReady`, `activeTextCapabilities`, `activeLocalTextCapabilities`, `isLiteRTAvailable`, `wantsLeadingThinkToken`) but there is no dedicated engines test file — they are only exercised indirectly through the hook tests. The repo standard requires 100% branch coverage on new code. Notably the llama Gemma-4 branch of `wantsLeadingThinkToken` (the `llmService.isGemma4Model() && llmService.isThinkingEnabled()` path returning true) is never exercised: every test mocks `isGemma4Model` to `false`; only the LiteRT-loaded + thinkingEnabled branch is covered. Add direct unit tests for engines.ts covering the remote, litert-loaded, and llama-gemma4 branches of these pure/registry functions so the coverage gate reflects real behavior rather than incidental coverage.

4. 💡 Edge Case: Unified vision patterns broaden local HF classification, risking false positives
   Files: src/utils/visionModel.ts:11-17, src/screens/ModelsScreen/utils.ts:42-46, src/services/huggingface.ts:178-182

   DR2 replaces the local 3-keyword vision check (vision/vlm/llava) with the full 24-entry VISION_NAME_PATTERNS via looksLikeVisionModel. That list contains broad, boundary-less substrings ('gpt-4o', 'claude-3', 'gemini', 'vlm', ':vl', 'vl-') that are matched against HuggingFace search result names/ids in getModelType and detectModelType. Community text-only repos commonly embed these tokens in their names (e.g. distillation fine-tunes named '...-gpt-4o-...', or repos containing 'gemini'/'claude-3'). getModelType returns 'Vision' before falling back to 'Text generation', so such text models get classified as Vision and can be hidden from the text list / miscategorized in ModelsScreen.
   
   This is intentional consistency with the remote detector (which already used these patterns), so severity is minor — but the local surface (HF search) is newly exposed to the false-positive risk. Consider narrowing the risky remote-only patterns (gpt-4o, claude-3, gemini) since those hosted models never appear as downloadable HF repos, or anchoring the VL substrings to word boundaries to avoid incidental matches.

   Fix (Keep cloud-only families (gpt-4o/claude-3/gemini) out of the list applied to HuggingFace search classification.):
   // Split remote-only cloud families (never HF-downloadable) from on-device VL families.
   export const VISION_NAME_PATTERNS: readonly string[] = [
     '-vl', 'vl-', ':vl', 'vlm',
     'vision', 'llava', 'bakllava', 'moondream', 'cogvlm',
     'cogagent', 'fuyu', 'idefics', 'qwen-vl', 'pixtral',
     'phi-3.5-vision', 'minicpm-v', 'internvl', 'yi-vl',
     'smolvlm', 'llama-3.2-vision',
   ];
   // Cloud-only names used only by the remote capability detector:
   export const REMOTE_VISION_NAME_PATTERNS: readonly string[] = [
     ...VISION_NAME_PATTERNS, 'gpt-4-vision', 'gpt-4o', 'claude-3', 'gemini',
   ];

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

alichherawalla added a commit that referenced this pull request Jul 10, 2026
…nced-prompt hole)

Device-confirmed: hitting Resend on an image turn's USER message loaded gemma4 and answered with
text instead of re-drawing. An image turn emits an "Enhanced prompt" assistant message (no image)
BEFORE the image-result message, and recordedTurnKind checked only the FIRST reply → 'text' → text
pipeline. Now it scans every assistant reply in the turn (until the next user message); any image
reply → 'image'. Both resend entry points (user-msg + assistant-msg) unified through recordedTurnKind
(removed assistantRetryKind), so tapping resend on the enhanced-prompt OR the image message re-draws.

Tests: enhanced-prompt-precedes-image → 'image'; turn boundary (image in a later turn doesn't leak).
Also added docs/DEVICE_TEST_LOG.md — logs every bug reported during PR #510 on-device testing (B1-B5)
+ the verified-passing list. 102 generation/handler tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alichherawalla and others added 9 commits July 12, 2026 03:55
…t files; 50 automated

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ic red) + imageOomCard (card render); UI over-commit needs a harness size-knob

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… nativeBoundary

installNativeBoundary({ whisper: true }) now overrides the dumb global whisper.rn stub with a
context whose transcribeRealtime is driveable (emitRealtime emits device-shaped
RealtimeTranscribeEvents incl. the B26 no-data symptom) and transcribeFile returns a canned
transcript. Unlocks the STT cluster (T075/T076/T077/T078/T080/T084/T085) as real UI flows over
the native STT boundary. Smoke suite still green; tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…del arrival, voice-record-button testID

setupChatScreen({whisper:true}) installs the whisper fake; setupWhisperModel() arrives at a
downloaded+selected+resident whisper via the REAL select gesture (seed file → refreshPresentModels →
tap present card); adds a voice-record-button testID on the hold-to-talk PanResponder view.
NOTE: tapMic() (firing the PanResponder grant via RNTL) does NOT yet trigger onStartRecording — hold-
to-talk gesture firing in jsdom is unresolved; the whisper-resident arrival + mic render ARE verified.
T075/T080 blocked on that gesture-firing technique, not on product behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…26/B28)

Adversarial UI-integration red for the P0 STT root. FULL ChatScreen mount, REAL hold-to-talk
gesture on the REAL VoiceRecordButton, real useVoiceInput + whisperService. The user speaks
'hello world' (recording captured it → transcribeFile returns it), but the realtime stream
emits the device-faithful no-data events (isCapturing:false/hasData:false). HEAD: chat-mode's
realtime-only pipeline ignores the recording, gets no data → the input stays EMPTY → RED.
OGAM ground truth: voice input is ALWAYS transcribed to TEXT.

Device-grounded (DEVICE_TEST_FINDINGS B26/B28): 'spoke a clear Hello how are you; blank
screen, nothing in the input box' — transcribeRealtime started → Event{isCapturing:false,
hasData:false} → no transcript; confirmed fundamental (fresh rebuild → same). Green comes from
the B28 fix (unify chat-mode onto the working file-transcribe pipeline), proven in FIX mode.

Harness: nativeBoundary grants RECORD_AUDIO when whisper installed (the mic-permission device
boundary — without it whisperService.requestPermissions denies and realtime never starts). The
gesture + realtime path were verified to wire once permission is faked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…er user)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…green guard deferred to Phase 2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…NKING)

The device case: a LiteRT model stopped while still THINKING lost the whole message. Reasoning streams to
streamingReasoningContent, but keepShownPartialOrClear checked streamingMessage ONLY (empty during thinking)
→ cleared it. Per the principle 'never discard shown output', always finalize when a conversation is
streaming — finalizeStreamingMessage persists content OR reasoning and resets either way (a strict superset
of clear); only clear when there is no streaming conversation at all. [STOP-SM] now logs content+reasoning length.

test: rendered — stop while only reasoning has streamed keeps the thinking block (llama+litert content cases
already covered); added scriptThinkingThenHang harness capability (LiteRT emits a reasoning token then holds).
…amber

A vision repair IS a download, but its progress bar, spinner, and 'Repairing' badge used colors.warning
(amber) while a normal download's progress uses colors.primary (the emerald theme). No reason to differ —
match the theme so the repair download looks like any other download.
…tert/remote)

Extends the Stop principle to the error path. The error handlers in generationServiceHelpers
(litert onError/catch, llama catch, remote onError/catch) all clearStreamingMessage on a mid-stream
error → the partial the user saw vanished. Route them through one keepShownPartialOnError (flush buffered
tokens, then finalize — persists content OR reasoning, resets either way). Also: the tool-loop catch in
generationService discarded tokenBuffer before finalizing, so keepShownPartialOrClear now flushes first,
and the catch no longer wipes the buffer. Pre-stream precondition bails (readiness/no-user-msg) stay as
clear (nothing was shown).

test: a mid-stream generation error keeps the streamed partial (errorClearsSpinner still green — the
spinner clears via resetState, independent of the message).
…ne (litert too)

A direct-audio (LiteRT) model diverged: chat-mode hold-to-talk transcribed then dispatched a voice-note
ATTACHMENT, while a non-audio (llama) model dropped the transcript into the composer for review/edit/send.
Unify it — Voice.ts's chat-mode direct-audio branch now calls onTranscript (composer), not onAudioAttachment.
Voice/Audio INTERFACE mode still attaches audio (separate path, unchanged). The model still only ever gets
the transcript, never raw audio.

tests: rendered — mount ChatScreen with an audio-capable LiteRT model, real hold-to-talk gesture, assert the
transcript lands in the input box and nothing is sent (red before the fix). Hook-level test updated to the
same intent. Added audio harness option + releaseMic gesture.
…tool call)

Gemma drops the trailing newline after <|channel>thought when the thought is
empty and it goes straight to a tool call (<|channel>thought<tool_call>…). The
grammar hardcoded a required \n in three places, so the bare opener survived
stripping and rendered as visible pre-text above the tool card (device
2026-07-14).

Single-source fix: REASONING_DELIMITERS now carries the BARE opener (the \n is
optional whitespace, not part of the delimiter); parseGemmaThinking + the strip
regex derive from one \n?-tolerant source; the streaming parser consumes the
one optional \n separator on block entry so reasoning carries no leading
newline. Both engines' output cleans through the one shared funnel.
…ler snapshot

Image Steps and cfg/guidance were off by one — changing them in Chat Settings
didn't affect the next generation, only the one after; image Size applied
immediately. handleImageGenerationFn threaded steps/guidanceScale from
deps.settings (a React render snapshot, one change stale) as explicit params,
and those OVERRODE imageGenerationService's fresh read. Width/height were never
passed, so the service read them fresh from getState() and were always current.

Stop passing steps/guidanceScale from the snapshot; the service already reads
all four tunables fresh from useAppStore.getState() at generation time. One
fresh source for every image tunable — no off-by-one, consistent with size.
…ja support

The quick-settings popover showed a Thinking toggle for Mistral 7B, which has no
native thinking support. supportsNativeThinking returned true for ANY model whose
Jinja template renders (isJinjaSupported() → true) — conflating 'has a working
template' with 'emits reasoning'. Mistral has a valid tool-use template but no
reasoning markers, so the toggle wrongly appeared.

Capability now derives SOLELY from templateEmitsReasoning against the model's own
chat_template (the same single-source predicate remote probing uses) — the <think>
/ Gemma-Qwen channel delimiters or the enable_thinking kwarg. Covers both jinja-
supported and OD7 jinja-unsupported reasoning models; both carry the template.

Harness: the llama fake now exposes the GGUF chat_template on context.model.metadata
(device-faithful), defaulting to a reasoning-capable template with a chatTemplate
override so a test can model a non-thinking model (Mistral). Deleted two mockist
llm.test cases that asserted the removed jinja→thinking mechanism via mock.calls.
- generationService: drop orphaned liteRTService import + unused chatStore local
  (both left dead by the stopAllTextEngines / keep-partial refactors)
- DownloadManagerScreen/items: extract modelTypeIconName/Color pure helpers so the
  card arrow drops back under the complexity cap (the wrench branch pushed it to 21)
- ModelsScreen/TextModelsTab: extract the LITERT_* recommended-model constants into
  litertRecommended.ts (file was 513 > 500 lines)
- scan.test: remove the unused makeFile helper + its RNFS import
Push-gate triage (branch was red before these 4 device fixes for reasons unrelated
to them):

- Harness: the default test model declared fileSize 4GB while its seeded file was
  500MB. Under the session's GPU-aware text overhead (2.2x on a non-CPU backend,
  e.g. iOS METAL) a 4GB model needs ~8.8GB and no longer fits the default 8GB-avail
  profile, so chat-flow tests spuriously hit the residency refusal. Give the model a
  realistic 2GB size (overridable via modelFileSizeBytes). This alone greened the
  entire image + GPU + reload-race integration cluster (11 suites).
- Deleted 11 mockist unit tests that asserted behavior this session deliberately
  changed: clear-on-error (now keep-the-partial), loose mmproj matching (now strict
  quant-stripped-stem), the direct activeModelId write (now service-owned), and an
  onComplete arg-shape (reasoning 2nd arg). Each is covered by a rendered integration
  test (errorKeepsPartial, stopKeepsPartial, mmProjMatchesModel, model-selection).
…icitly

ejectAll + modelSelectorEjectResident assert residents == [image, whisper] after an
image load — which requires the text model to be heavy enough that loading the image
model EVICTS it (one heavy at a time). The harness default is now a lighter 2GB model
(fits chat-flow budgets), which would co-reside and leave 3 residents. Both tests now
pass modelFileSizeBytes: 4GB so their eviction premise holds; assertions unchanged.
…al reclaim wait

The 3 GPU reload tests waited only 4s for the reload banner to clear, written when
reload was instant. The session's reclaim barrier (awaitMemoryReclaim, up to 2.5s)
makes reload legitimately slower; under the full gate's heavy parallel CPU load that
4s window was exceeded, flaking these suites (they passed in-band + isolated). Widen
the reload waitFors to 20s and the it-level timeouts to 30s. No product/ assertion
change — the tests now allow the correct (slower) reload to finish.
…rt cycle

ModelRowType was defined in ModelsManagerSheet but imported by useResidentRows, which
the sheet imports back — a dependency-cruiser no-circular violation. Move the type to
the lower-level hook (useResidentRows) and re-export it from the sheet so Home, Chat,
and ModelsSummaryRow keep importing it unchanged. Behavior-neutral.
…akes effect)

Selecting Lean (or any mode) with several models resident left them all in RAM —
setLoadPolicy only governs FUTURE loads, so the already-resident set was untouched
until the next load (device 2026-07-14: TEXT+IMAGE+VOICE+SPEECH all stayed loaded).

loadPolicySync (the single setting->policy projection) now ejects every resident on a
real mode CHANGE, reusing the existing activeModelService.ejectAll — each selected
model lazily reloads on next use under the new mode. The boot seed is guarded so the
initial policy set never ejects; only a user change does. Layering holds (the low-level
residency manager isn't the one calling up to ejectAll; the projection coordinates it).
…top desync)

generateRemoteWithToolsImpl had no error handler — a provider/HTTP error thrown from
runToolLoop propagated past the state reset, leaving isGenerating=true. The red stop
button stayed up after the error dialog, and every subsequent send aborted at
prepareGeneration (isGenerating check) BEFORE reaching the server (device 2026-07-14:
error shown but turn never stopped; server never re-called on retry). Add the same
catch the local tool path already has: keepShownPartialOnError flushes any shown
partial, finalizes, and resets the generating state; the rethrow still surfaces the
dialog. Dialog and stop now happen together.
…ge), not side-by-side

A message with both a voice note and an image rendered them in a row, which looked
broken. Column layout so the voice note sits above the image.
…ture, de-export dead symbols

- CI ran Node 20 but dependency-cruiser@18 needs 22+ (architecture gate hard-failed);
  bump all ci.yml jobs to Node 22 (local is 26).
- typecheck + architecture jobs never checked out the pro submodule, so tsc saw
  __tests__/pro/* importing @offgrid/pro/* with no source (TS2307) and knip/depcruise
  scanned an incomplete graph. Add the same 'Check out pro submodule' step the test job has.
- Drop the unused 'export' on 5 internally-used symbols knip flagged as dead exports
  (ROW_RESIDENT_TYPE, residentsByRow, isToolGrammarError, modelIdentityStem,
  MIN_MODEL_FILE_SIZE) — behavior-neutral, just narrows visibility.
…rially

- Remove OVERRIDE_SURVIVAL_FLOOR_MB / ANDROID_OVERRIDE_SURVIVAL_FLOOR_MB /
  overrideSurvivalFloorMB: defined but never called (enforced nothing), and the product
  decision is that Load Anyway gives the user FULL control — no app-imposed hard floor.
  Clears the last knip unused-export.
- CI test job runs 'jest --runInBand' (matches what passes locally); the heavy RTL
  integration suites flaked only under the parallel --coverage run, not serially.
…apper (88%), block fills it (100%)

The width fix moved the 88% from the inner block to the wrapper (killing the double-88%
that rendered ~77%). Update the assertion to walk up to the wrapper carrying the bubble
width and verify the inner block fills it — matches the device-confirmed layout.
…ol can't overflow context

A badly-behaved tool can return an unbounded payload — device 2026-07-14: the deepwiki MCP
read_wiki_contents returned 1.27M chars, which overflowed the remote model's context and wedged
the turn (the server can't TruncateMiddle a prompt with an image). toolResultModelContent (the ONE
seam every tool result — MCP + built-in — passes through) now truncates an oversized result to the
first 24k chars with a note telling the model it was cut. Upstream-agnostic: protects against any
oversized result regardless of how the tool is written.
…vider test

Two Linux-CI-only failures the case-insensitive macOS dev box never surfaced:

1. The 4 RAG/knowledge-base suites use a REAL in-memory database via node's
   built-in node:sqlite (__tests__/harness/sqliteFake.ts) — the correct
   'prefer real over a fake' doctrine. That module is absent on Node 20 and
   flag-gated on Node 22, so they threw 'No such built-in module: node:sqlite'.
   Pin the test job to Node 24 (node:sqlite is flag-free from 23.4+).

2. __tests__/unit/services/textDownloadProvider.test.ts jest.mock'd our own
   huggingFace service at '.../huggingFace' (wrong case; the file is
   huggingface.ts) — resolves on macOS, fails on Linux. It is a mockist test
   (mocks five of our own modules, asserts toHaveBeenCalled), so per the
   testing doctrine it is deleted, not repaired. textProvider stays covered by
   modelDownloadService.test.ts + DownloadManagerScreen.test.tsx.
The test job segfaulted on Node 24 (exit 139) during jest's --forceExit
teardown, after all tests passed — the crash is in node:sqlite's native
teardown, which the RAG/knowledge-base suites exercise via the real in-memory
DB harness.

Reproduced locally with a downloaded Node 24.18.0 vs system Node 26, running
the full suite (--coverage --forceExit --runInBand):
  - Node 24: exit 139 (segfault), with AND without an attempt to close every
    sqlite handle in teardown — so it is not our open handles, it is Node 24's
    node:sqlite teardown path.
  - Node 26: exit 0, 532 suites / 7817 tests pass, coverage gate green.

Node 26's node:sqlite fixes the teardown crash, so pin the test runtime to 26.
…ol the clock)

batch7 'newest-updated conversation is first' relied on wall-clock ms
resolution: createConversation stamps updatedAt with a raw Date, addMessage
bumps via max(now, prev+1). When 'newer' creation and 'older' bump land in the
same millisecond the two updatedAt values TIE and the sort order is undefined —
green under --coverage (slow) but a flaky failure on fast CI runs without it
(surfaced in the pro combined suite on a fast runner). Advance a fake clock so
the three stamps strictly increase; tests the real 'touched last sorts first'
behavior with no wall-clock race. Verified 5x green + the ordering case.
@gitar-bot

gitar-bot Bot commented Jul 14, 2026

Copy link
Copy Markdown
Code Review 👍 Approved with suggestions 2 resolved / 6 findings

Consolidates reasoning and tool-call parsing into a single grammar while implementing platform-aware memory management to prevent OOM crashes. Consider addressing the trailing partial-opener flush in ThinkTagParser, the missing fallback in regenerateResponse, and narrowing the VISION_NAME_PATTERNS to avoid classification false positives.

💡 Edge Case: ThinkTagParser never flushes trailing partial-opener at stream end

📄 src/services/providers/openAICompatibleStream.ts:39-53 📄 src/services/providers/openAICompatibleStream.ts:96-103

In the generalized ThinkTagParser, handleOutsideThink now holds back the longest suffix of the buffer that is a prefix of ANY reasoning opener (maxPartialTagSuffix over REASONING_DELIMITERS). Since there is no end-of-stream flush of the parser (the only entry point is process(), called per-delta in processDelta, and finalizeStreamingMessage consumes only the accumulated onToken output), a held-back suffix on the final chunk is silently dropped from the visible answer.

This was largely pre-existing for <think>, but the new grammar widens the withheld set: openers like <|channel|>analysis<|message|> and <|channel>thought mean a final chunk ending in <| (previously emitted, since <| is not a prefix of <think>) is now held back and lost. The risk is low (remote answers rarely end mid-token on a tag-like prefix) but real. Consider adding an explicit end()/flush() method that emits any residual buffer, and call it when the stream completes.

Flush residual buffer when the stream ends so a trailing partial-opener is not dropped.
// Add to ThinkTagParser and call on stream completion (after the last delta):
end(onToken: (t: string) => void, onReasoning: (t: string) => void): void {
  if (this.buffer.length === 0) return;
  // No more data is coming: a held-back partial tag is just text/reasoning now.
  if (this.inThinkBlock) onReasoning(this.buffer);
  else onToken(this.buffer);
  this.buffer = '';
}
💡 Edge Case: regenerate no longer falls back to text when image model is gone

📄 src/screens/ChatScreen/useChatGenerationActions.ts:493-497 📄 src/screens/ChatScreen/useChatGenerationActions.ts:420-427

In regenerateResponseFn the old guard if (shouldGenerateImage && deps.activeImageModel) was replaced by if (kind === 'image'), dropping the deps.activeImageModel check (src/screens/ChatScreen/useChatGenerationActions.ts:493-497). For a recorded image turn whose image model was later removed/deselected, a resend/edit now routes to handleImageGenerationFn, which alerts "No image model loaded" and returns — it no longer falls through to text generation. Note dispatchGenerationFn KEPT the && deps.activeImageModel guard (line ~424), so the two seams that were unified now diverge in this edge case. This is defensible behavior (a recorded image turn should stay an image turn), but the divergence is subtle and worth an explicit decision. For legacy turns (no recordedKind) there is no regression because shouldRouteToImageGenerationFn already returns false when activeImageModel is falsy.

💡 Quality: New engines.ts functions have no direct test; some branches uncovered

📄 src/services/engines.ts:97-111 📄 tests/unit/hooks/useChatGenerationActions.test.ts

engines.ts adds several exported functions (isModelReady, activeTextCapabilities, activeLocalTextCapabilities, isLiteRTAvailable, wantsLeadingThinkToken) but there is no dedicated engines test file — they are only exercised indirectly through the hook tests. The repo standard requires 100% branch coverage on new code. Notably the llama Gemma-4 branch of wantsLeadingThinkToken (the llmService.isGemma4Model() && llmService.isThinkingEnabled() path returning true) is never exercised: every test mocks isGemma4Model to false; only the LiteRT-loaded + thinkingEnabled branch is covered. Add direct unit tests for engines.ts covering the remote, litert-loaded, and llama-gemma4 branches of these pure/registry functions so the coverage gate reflects real behavior rather than incidental coverage.

💡 Edge Case: Unified vision patterns broaden local HF classification, risking false positives

📄 src/utils/visionModel.ts:11-17 📄 src/screens/ModelsScreen/utils.ts:42-46 📄 src/services/huggingface.ts:178-182

DR2 replaces the local 3-keyword vision check (vision/vlm/llava) with the full 24-entry VISION_NAME_PATTERNS via looksLikeVisionModel. That list contains broad, boundary-less substrings ('gpt-4o', 'claude-3', 'gemini', 'vlm', ':vl', 'vl-') that are matched against HuggingFace search result names/ids in getModelType and detectModelType. Community text-only repos commonly embed these tokens in their names (e.g. distillation fine-tunes named '...-gpt-4o-...', or repos containing 'gemini'/'claude-3'). getModelType returns 'Vision' before falling back to 'Text generation', so such text models get classified as Vision and can be hidden from the text list / miscategorized in ModelsScreen.

This is intentional consistency with the remote detector (which already used these patterns), so severity is minor — but the local surface (HF search) is newly exposed to the false-positive risk. Consider narrowing the risky remote-only patterns (gpt-4o, claude-3, gemini) since those hosted models never appear as downloadable HF repos, or anchoring the VL substrings to word boundaries to avoid incidental matches.

Keep cloud-only families (gpt-4o/claude-3/gemini) out of the list applied to HuggingFace search classification.
// Split remote-only cloud families (never HF-downloadable) from on-device VL families.
export const VISION_NAME_PATTERNS: readonly string[] = [
  '-vl', 'vl-', ':vl', 'vlm',
  'vision', 'llava', 'bakllava', 'moondream', 'cogvlm',
  'cogagent', 'fuyu', 'idefics', 'qwen-vl', 'pixtral',
  'phi-3.5-vision', 'minicpm-v', 'internvl', 'yi-vl',
  'smolvlm', 'llama-3.2-vision',
];
// Cloud-only names used only by the remote capability detector:
export const REMOTE_VISION_NAME_PATTERNS: readonly string[] = [
  ...VISION_NAME_PATTERNS, 'gpt-4-vision', 'gpt-4o', 'claude-3', 'gemini',
];
✅ 2 resolved
Quality: Vision derivation drops the activeModelMmProjPath guard on migration

📄 src/screens/ChatScreen/useChatModelActions.ts:375-385 📄 src/services/engines.ts:56-62
The prior useChatModelStateSync vision effect only set vision from the llama engine when activeModelMmProjPath && llmService.isModelLoaded(). The migrated path via deriveEngineCapabilities computes llama vision purely as i.llama.loaded ? i.llama.vision : false where i.llama.vision = llmService.getMultimodalSupport()?.vision ?? false, dropping the activeModelMmProjPath gate (the effect still lists it in the dependency array, but the value is no longer used in the derivation).

This is likely behavior-neutral in practice (multimodal support should only report vision: true when a projector was actually initialized), so it is not confirmed as a bug. However, if the llama engine can retain multimodal state from a previously-loaded vision model during an in-place model switch, the new derivation could report vision: true for a freshly-selected non-vision model until reload. Worth a quick check that getMultimodalSupport().vision is always false without a projector, or restore the mmproj gate to preserve the exact prior contract.

Bug: Imports reference missing module utils/remoteCapabilityDetect

📄 src/services/remoteServerManagerUtils.ts:60-62 📄 src/stores/remoteServerHelpers.ts:16-19
The delta moves the pure capability detectors out of remoteServerManagerUtils.ts and re-exports them from ../utils/remoteCapabilityDetect, and remoteServerHelpers.ts now imports detectVisionCapability/detectToolCallingCapability from the same path. However, no such module exists in the repo: src/utils/remoteCapabilityDetect.ts (and any .tsx/index variant) is not present, and grepping for export function detectVisionCapability / export function detectToolCallingCapability returns zero definitions anywhere in the tree.

Because the original implementations were deleted from remoteServerManagerUtils.ts in this same commit, both symbols are now completely undefined. Every module that resolves these imports (remoteServerManagerUtils.ts re-export, remoteServerHelpers.ts) will fail module resolution — a TypeScript compile error and a runtime import failure. Remote model vision/tool-calling capability detection is broken, and anything importing these modules (remote server discovery/management) will crash on load.

This directly contradicts the PR's "tsc clean" gate claim. Fix by actually adding src/utils/remoteCapabilityDetect.ts exporting both functions (moving the deleted logic there, with looksLikeVisionModel imported from ../utils/visionModel), or point the imports back at a module that actually defines them.

🤖 Prompt for agents
Code Review: Consolidates reasoning and tool-call parsing into a single grammar while implementing platform-aware memory management to prevent OOM crashes. Consider addressing the trailing partial-opener flush in ThinkTagParser, the missing fallback in regenerateResponse, and narrowing the VISION_NAME_PATTERNS to avoid classification false positives.

1. 💡 Edge Case: ThinkTagParser never flushes trailing partial-opener at stream end
   Files: src/services/providers/openAICompatibleStream.ts:39-53, src/services/providers/openAICompatibleStream.ts:96-103

   In the generalized `ThinkTagParser`, `handleOutsideThink` now holds back the longest suffix of the buffer that is a prefix of ANY reasoning opener (`maxPartialTagSuffix` over `REASONING_DELIMITERS`). Since there is no end-of-stream flush of the parser (the only entry point is `process()`, called per-delta in `processDelta`, and `finalizeStreamingMessage` consumes only the accumulated `onToken` output), a held-back suffix on the final chunk is silently dropped from the visible answer.
   
   This was largely pre-existing for `<think>`, but the new grammar widens the withheld set: openers like `<|channel|>analysis<|message|>` and `<|channel>thought` mean a final chunk ending in `<|` (previously emitted, since `<|` is not a prefix of `<think>`) is now held back and lost. The risk is low (remote answers rarely end mid-token on a tag-like prefix) but real. Consider adding an explicit `end()`/`flush()` method that emits any residual buffer, and call it when the stream completes.

   Fix (Flush residual buffer when the stream ends so a trailing partial-opener is not dropped.):
   // Add to ThinkTagParser and call on stream completion (after the last delta):
   end(onToken: (t: string) => void, onReasoning: (t: string) => void): void {
     if (this.buffer.length === 0) return;
     // No more data is coming: a held-back partial tag is just text/reasoning now.
     if (this.inThinkBlock) onReasoning(this.buffer);
     else onToken(this.buffer);
     this.buffer = '';
   }

2. 💡 Edge Case: regenerate no longer falls back to text when image model is gone
   Files: src/screens/ChatScreen/useChatGenerationActions.ts:493-497, src/screens/ChatScreen/useChatGenerationActions.ts:420-427

   In `regenerateResponseFn` the old guard `if (shouldGenerateImage && deps.activeImageModel)` was replaced by `if (kind === 'image')`, dropping the `deps.activeImageModel` check (src/screens/ChatScreen/useChatGenerationActions.ts:493-497). For a recorded image turn whose image model was later removed/deselected, a resend/edit now routes to `handleImageGenerationFn`, which alerts "No image model loaded" and returns — it no longer falls through to text generation. Note `dispatchGenerationFn` KEPT the `&& deps.activeImageModel` guard (line ~424), so the two seams that were unified now diverge in this edge case. This is defensible behavior (a recorded image turn should stay an image turn), but the divergence is subtle and worth an explicit decision. For legacy turns (no recordedKind) there is no regression because `shouldRouteToImageGenerationFn` already returns false when `activeImageModel` is falsy.

3. 💡 Quality: New engines.ts functions have no direct test; some branches uncovered
   Files: src/services/engines.ts:97-111, __tests__/unit/hooks/useChatGenerationActions.test.ts

   engines.ts adds several exported functions (`isModelReady`, `activeTextCapabilities`, `activeLocalTextCapabilities`, `isLiteRTAvailable`, `wantsLeadingThinkToken`) but there is no dedicated engines test file — they are only exercised indirectly through the hook tests. The repo standard requires 100% branch coverage on new code. Notably the llama Gemma-4 branch of `wantsLeadingThinkToken` (the `llmService.isGemma4Model() && llmService.isThinkingEnabled()` path returning true) is never exercised: every test mocks `isGemma4Model` to `false`; only the LiteRT-loaded + thinkingEnabled branch is covered. Add direct unit tests for engines.ts covering the remote, litert-loaded, and llama-gemma4 branches of these pure/registry functions so the coverage gate reflects real behavior rather than incidental coverage.

4. 💡 Edge Case: Unified vision patterns broaden local HF classification, risking false positives
   Files: src/utils/visionModel.ts:11-17, src/screens/ModelsScreen/utils.ts:42-46, src/services/huggingface.ts:178-182

   DR2 replaces the local 3-keyword vision check (vision/vlm/llava) with the full 24-entry VISION_NAME_PATTERNS via looksLikeVisionModel. That list contains broad, boundary-less substrings ('gpt-4o', 'claude-3', 'gemini', 'vlm', ':vl', 'vl-') that are matched against HuggingFace search result names/ids in getModelType and detectModelType. Community text-only repos commonly embed these tokens in their names (e.g. distillation fine-tunes named '...-gpt-4o-...', or repos containing 'gemini'/'claude-3'). getModelType returns 'Vision' before falling back to 'Text generation', so such text models get classified as Vision and can be hidden from the text list / miscategorized in ModelsScreen.
   
   This is intentional consistency with the remote detector (which already used these patterns), so severity is minor — but the local surface (HF search) is newly exposed to the false-positive risk. Consider narrowing the risky remote-only patterns (gpt-4o, claude-3, gemini) since those hosted models never appear as downloadable HF repos, or anchoring the VL substrings to word boundaries to avoid incidental matches.

   Fix (Keep cloud-only families (gpt-4o/claude-3/gemini) out of the list applied to HuggingFace search classification.):
   // Split remote-only cloud families (never HF-downloadable) from on-device VL families.
   export const VISION_NAME_PATTERNS: readonly string[] = [
     '-vl', 'vl-', ':vl', 'vlm',
     'vision', 'llava', 'bakllava', 'moondream', 'cogvlm',
     'cogagent', 'fuyu', 'idefics', 'qwen-vl', 'pixtral',
     'phi-3.5-vision', 'minicpm-v', 'internvl', 'yi-vl',
     'smolvlm', 'llama-3.2-vision',
   ];
   // Cloud-only names used only by the remote capability detector:
   export const REMOTE_VISION_NAME_PATTERNS: readonly string[] = [
     ...VISION_NAME_PATTERNS, 'gpt-4-vision', 'gpt-4o', 'claude-3', 'gemini',
   ];

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant