Skip to content

Commit ff6efed

Browse files
review: address CodeRabbit + Qodo — fix seam/DRY findings
- messageContent no longer imports ParsedContent from the ChatMessage component: the type is OWNED by the util that produces it and re-exported from ChatMessage/types (Qodo: utils->UI dependency contradicted the refactor's goal). Correct dependency direction. - parseModelOutput.contract test: MARKUP matcher is now DERIVED from the exported grammar (REASONING_DELIMITERS + TOOL_CALL_OPENERS/CLOSERS) instead of a hand-rolled regex that could drift and miss vectors (CodeRabbit: the exact drift class this PR eliminates). - partialSuffix/maxPartialSuffix consolidated into shared partialTagSuffix/maxPartialTagSuffix in messageContent; ThinkTagParser and ToolCallTokenFilter both consume them (CodeRabbit DRY: the primitive was duplicated verbatim in two parsers). - GAPS_BACKLOG: removed em dashes and the banned 'stands as' filler from the newly-added notes (Qodo copy rules). Gate: eslint+tsc clean; parsers + contract + streaming + grammar + tool-gen 272 tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 143f2b7 commit ff6efed

6 files changed

Lines changed: 75 additions & 54 deletions

File tree

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,30 @@
11
import { parseModelOutput } from '../../../../src/components/ChatMessage/utils';
2+
import {
3+
REASONING_DELIMITERS,
4+
TOOL_CALL_OPENERS,
5+
TOOL_CALL_CLOSERS,
6+
} from '../../../../src/utils/messageContent';
27

38
// Contract: parseModelOutput().answer is GUARANTEED free of reasoning + tool-call markup for
4-
// EVERY format the app can emit. This invariant makes the tool-call-leak class impossible —
5-
// if a new markup format is added to the parser, add it here.
6-
const MARKUP = /<think>|<\/think>|<\|channel|<tool_call>|<\|tool_call|<function=|<parameter=|<invoke|<function_call/;
9+
// EVERY format the app can emit. The MARKUP matcher is DERIVED from the same single-source
10+
// grammar the parser/stripper use (REASONING_DELIMITERS + TOOL_CALL_OPENERS/CLOSERS) plus the
11+
// function-style tokens, so a new opener/closer added to the grammar is guarded automatically —
12+
// the matcher can't silently drift out of sync with the parser (the exact class this PR kills).
13+
const GRAMMAR_TOKENS = [
14+
...REASONING_DELIMITERS.flatMap(d => [d.open, d.close]),
15+
...TOOL_CALL_OPENERS,
16+
...TOOL_CALL_CLOSERS,
17+
// Function-style tokens parsed by generationToolLoop but not part of the delimiter grammar.
18+
'<function=',
19+
'</function>',
20+
'<parameter=',
21+
'</parameter>',
22+
'<invoke',
23+
'<function_call>',
24+
];
25+
const MARKUP = new RegExp(
26+
GRAMMAR_TOKENS.map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'),
27+
);
728

829
describe('parseModelOutput — answer is clean by construction (the anti-leak contract)', () => {
930
const toolBlock = '<tool_call>\n<function=search_kb>\n<parameter=query>\nAchilles\n</parameter>\n</function>\n</tool_call>';

docs/GAPS_BACKLOG.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -233,14 +233,14 @@ by a service. Two findings (DR1, DR3) are root-cause siblings of today's shipped
233233
| DR7 | llmToolGeneration:32 (filter) vs generationToolLoop:118 (parser) Gemma tool delimiters | DRIFTED-minor | Parser accepts <tool_call: opener the filter doesn't suppress → tokens flash. Shared GEMMA_TOOLCALL_DELIMITERS. |
234234
| DR8 | remoteModelCapabilities:202 deltaHasThinking vs openAICompatibleStream:155 | DEBT | Shared REASONING_DELTA_FIELDS + deltaHasReasoning(delta). |
235235

236-
### Test quality (§D) 371 files, ~13 with a genuinely weak top-tier block
236+
### Test quality (§D) - 371 files, ~13 with a genuinely weak top-tier block
237237
| # | File | Verdict | Fix |
238238
|---|------|---------|-----|
239-
| TQ1 | __tests__/**/useDownloads.test.ts | WORST | Fakes the reducer under test (hand-sets entry.status then asserts the spy) 37 call-asserts, 0 real-state. Drive real useDownloadStore; assert getState().downloads[key].status. |
240-
| TQ2 | ChatScreenSpotlight (step 3→12 block) | WORST | Block ends after advanceTimersByTime with ZERO expect() can never fail. Assert the coachmark text. |
239+
| TQ1 | __tests__/**/useDownloads.test.ts | WORST | Fakes the reducer under test (hand-sets entry.status then asserts the spy) - 37 call-asserts, 0 real-state. Drive real useDownloadStore; assert getState().downloads[key].status. |
240+
| TQ2 | ChatScreenSpotlight (step 3→12 block) | WORST | Block ends after advanceTimersByTime with ZERO expect() - can never fail. Assert the coachmark text. |
241241
| TQ3 | Spotlight trio (Chat/Home/ModelSettings Spotlight, ~40 tests) | HIGH | Assert goTo(<int>) not the coachmark; unmock react-native-spotlight-tour, assert getByText(coachmark). |
242242
| TQ4 | useChatGenerationActions.test.ts (132 called vs 16) | HIGH | L932 tautology + mock-on-mock "message appeared"; assert store/rendered outcome. |
243-
| TQ5 | coreMLModelUtils "downloads sequentially" | MED | Asserts order that only holds by .map push order while impl uses Promise.all false guarantee. Assert real ordering w/ dynamic out-of-order mock or drop the claim. |
243+
| TQ5 | coreMLModelUtils "downloads sequentially" | MED | Asserts order that only holds by .map push order while impl uses Promise.all - false guarantee. Assert real ordering w/ dynamic out-of-order mock or drop the claim. |
244244
| TQ6 | render tests w/ no getByText: TTSButton, ModelFailureCard, ImageGenAdviceCard, ToolAccordionStreaming, ModelsManagerSheet, McpAddServerSheet, PlaybackControls, KokoroTTSBridge | MED | Assert visible content/state, not just container testID. |
245245

246246
## Parse-once-at-boundary refactor - progress + remaining (2026-07-09)
@@ -249,28 +249,28 @@ Pattern: parse raw model output ONCE into a typed model; render from it, never r
249249
DONE (committed on main):
250250
- Step 1 KEYSTONE: parseModelOutput(content, reasoningContent?) → {reasoning, answer} in ChatMessage/utils.ts; answer clean-by-construction; contract test (parseModelOutput.contract.test.ts) asserts answer has NO markup for every format; buildMessageData delegates to it. 179 render/audio tests green together.
251251

252-
REMAINING (each a hub migration grep callers, run ALL their tests in ONE invocation before commit; render tests assert BOTH what appears AND what must not):
252+
REMAINING (each a hub migration - grep callers, run ALL their tests in ONE invocation before commit; render tests assert BOTH what appears AND what must not):
253253
- Step 2: point remaining direct parseThinkingContent/stripControlTokens RENDER callers at parseModelOutput.
254-
- Step 3 (DR1, real remote bug + PREREQUISITE = MOVE parseModelOutput + parseThinkingContent DOWN to src/utils/messageContent.ts so store/service layers can import without backwards layering; re-export from ChatMessage/utils for back-compat). Then collapse chatStore.extractChannelThinking + providers/openAICompatibleStream.ThinkTagParser (only knows <think>, leaks channel formats remotely OD16) onto the shared parser/grammar. Touches streaming + finalize do as ONE careful wave in a fresh session.
254+
- Step 3 (DR1, real remote bug + PREREQUISITE = MOVE parseModelOutput + parseThinkingContent DOWN to src/utils/messageContent.ts so store/service layers can import without backwards layering; re-export from ChatMessage/utils for back-compat). Then collapse chatStore.extractChannelThinking + providers/openAICompatibleStream.ThinkTagParser (only knows <think>, leaks channel formats remotely - OD16) onto the shared parser/grammar. Touches streaming + finalize - do as ONE careful wave in a fresh session.
255255
- Step 4 (DR7): unify tool-call delimiters between stripControlTokens and generationToolLoop parseToolCallsFromText/parseGemmaNativeToolCalls (one GRAMMAR; parser accepts <tool_call: opener the stream filter doesn't suppress → flashes).
256256
- Step 5: delete dead duplicate parsers; full suite.
257-
Note: Step 4 (store-time parse of the persisted Message shape) is the deepest cut evaluate after 2-4; changes persistence.
257+
Note: Step 4 (store-time parse of the persisted Message shape) is the deepest cut - evaluate after 2-4; changes persistence.
258258

259-
### UPDATE 2026-07-09 branch refactor/parse-once-boundary (Steps A-C + native-first)
260-
DONE (committed on branch, all gates green prettier/eslint/tsc/26 suites·834 tests/android bundle):
259+
### UPDATE 2026-07-09 - branch refactor/parse-once-boundary (Steps A-C + native-first)
260+
DONE (committed on branch, all gates green - prettier/eslint/tsc/26 suites·834 tests/android bundle):
261261
- Step A: moved parseThinkingContent + parseModelOutput + ParsedModelOutput DOWN to src/utils/messageContent.ts (util layer); ChatMessage/utils re-exports for back-compat.
262262
- Step B (DR1): added REASONING_DELIMITERS (single grammar); deleted chatStore.extractChannelThinking+sliceThinkingBlock (route finalize through parseModelOutput); generalized ThinkTagParser (remote stream) from <think>-only to the shared grammar. Contract test (reasoningGrammar) + INTEGRATION test (reasoningPipeline: real store→finalize→render, local + remote flows, all formats, no-leak) green.
263263
- Step C (DR7): added TOOL_CALL_OPENERS/CLOSERS (single grammar); stripControlTokens + ToolCallTokenFilter both derive from it (fixes the <tool_call: colon leak the parser accepted but stripper/filter missed). Contract test (toolCallGrammar) across full opener×closer matrix + char-by-char.
264264
- 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.
265265

266-
REMAINING Step 5 = ON-DEVICE PROOF (GATE before any beta/release, §H):
266+
REMAINING - Step 5 = ON-DEVICE PROOF (GATE before any beta/release, §H):
267267
- The native-first flip is a RUNTIME behavior change, NOT verified on-device. Run a Gemma4 thinking + tool-call flow on Android dev build (ai.offgridmobile.dev) AND iOS; pull Documents/offgrid-debug.log; grep [GEMMA-FALLBACK].
268268
- If it NEVER fires → native 'auto' works → DELETE parseGemmaNativeToolCalls + Gemma <|channel> branches (dead) + narrow the hand-parsers to the remote-only fallback.
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.)
269+
- If it fires → native 'auto' does NOT cover Gemma in this llama.rn build → keep the hand-parser; the grammar work is the fallback. (Relates to OD13: reasoning_format vs actual channel-format mismatch - 'auto' may also fix OD13.)
270270
- Must not ship the native-first flip in a beta until this device check passes (TestFlight is distribution-signed → no container logs; verify on the dev build first).
271271

272272
## Pre-existing: mid-chat model switch doesn't refresh chat state until remount - 2026-07-10
273-
**instrument-and-revisit** | Reported on-device (iOS, gemma-4 local + remote), confirmed present on the OLD build (NOT introduced by the parse-once/selection/whisper work). Loading a new model from within the Chat screen mid-conversation does not update the screen's derived active-model state it's not a freeze/hang; navigating Home → back into the chat re-syncs and it works. Suspect useChatModelStateSync / the chat's derived activeModel not re-running after an in-chat load (the model loads fine; only the screen's projection is stale). Fix separately with its own on-device repro do NOT bundle into the current release PR (scope + risk).
273+
**instrument-and-revisit** | Reported on-device (iOS, gemma-4 local + remote), confirmed present on the OLD build (NOT introduced by the parse-once/selection/whisper work). Loading a new model from within the Chat screen mid-conversation does not update the screen's derived active-model state - it's not a freeze/hang; navigating Home → back into the chat re-syncs and it works. Suspect useChatModelStateSync / the chat's derived activeModel not re-running after an in-chat load (the model loads fine; only the screen's projection is stale). Fix separately with its own on-device repro - do NOT bundle into the current release PR (scope + risk).
274274

275275
## Reverted: Android ZRAM-swap Load-Anyway credit caused an OOM - 2026-07-10
276-
**resolved (reverted)** | Fix A (getOverrideAvailableMemoryGB crediting free ZRAM swap to the override survival floor) was WRONG for DIRTY models: GPU/LiteRT memory cannot be swapped, so a 5.2GB dirty Gemma-4-E4B loaded into ~4.5GB physical (swap-inclusive ceiling said "fits") and the device OOM-killed the app during generation (device log 19:03Z: `OVERRIDE - forcing load` with no REFUSE, then SIGKILL, no tombstone). Reverted both commits restores the conservative physical survival floor (the shipped-safe behavior that was correctly refusing these). The original "LiteRT Load-Anyway refused on tight memory" is the SAFE behavior; making large LiteRT loads work needs real on-device memory profiling (physical-fit for dirty + killable-background accounting), verified on the dev build not a swap-credit heuristic.
276+
**resolved (reverted)** | Fix A (getOverrideAvailableMemoryGB crediting free ZRAM swap to the override survival floor) was WRONG for DIRTY models: GPU/LiteRT memory cannot be swapped, so a 5.2GB dirty Gemma-4-E4B loaded into ~4.5GB physical (swap-inclusive ceiling said "fits") and the device OOM-killed the app during generation (device log 19:03Z: `OVERRIDE - forcing load` with no REFUSE, then SIGKILL, no tombstone). Reverted both commits - restores the conservative physical survival floor (the shipped-safe behavior that was correctly refusing these). The original "LiteRT Load-Anyway refused on tight memory" is the SAFE behavior; making large LiteRT loads work needs real on-device memory profiling (physical-fit for dirty + killable-background accounting), verified on the dev build - not a swap-credit heuristic.

src/components/ChatMessage/types.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,6 @@ export interface ChatMessageProps {
1818
metaExtra?: React.ReactNode;
1919
}
2020

21-
export interface ParsedContent {
22-
thinking: string | null;
23-
response: string;
24-
isThinkingComplete: boolean;
25-
thinkingLabel?: string;
26-
}
21+
// ParsedContent is owned by the util that produces it (utils/messageContent). Re-exported here
22+
// so existing component imports (`./types`) keep working without utils depending on this module.
23+
export type { ParsedContent } from '../../utils/messageContent';

src/services/llmToolGeneration.ts

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { ToolCall } from './tools/types';
99
import { recordGenerationStats, buildCompletionParams, buildThinkingCompletionParams, safeCompletion } from './llmHelpers';
1010
import type { StreamToken } from './llm';
1111
import logger from '../utils/logger';
12-
import { TOOL_CALL_OPENERS, TOOL_CALL_CLOSERS } from '../utils/messageContent';
12+
import { TOOL_CALL_OPENERS, TOOL_CALL_CLOSERS, maxPartialTagSuffix } from '../utils/messageContent';
1313

1414
type ToolStreamCallback = (data: StreamToken) => void;
1515
type ToolCompleteCallback = (fullResponse: string) => void;
@@ -42,7 +42,7 @@ export class ToolCallTokenFilter {
4242
// Inside a tool block: end it at the NEAREST closer of any form.
4343
const closeIdx = this.earliestIndex(TOOL_CALL_CLOSERS);
4444
if (closeIdx === -1) {
45-
const partial = this.maxPartialSuffix(TOOL_CALL_CLOSERS);
45+
const partial = maxPartialTagSuffix(this.buffer, TOOL_CALL_CLOSERS);
4646
this.buffer = partial > 0 ? this.buffer.slice(this.buffer.length - partial) : '';
4747
break;
4848
}
@@ -52,7 +52,7 @@ export class ToolCallTokenFilter {
5252
// Outside: enter a block at the EARLIEST opener of any form.
5353
const openIdx = this.earliestIndex(TOOL_CALL_OPENERS);
5454
if (openIdx === -1) {
55-
const partial = this.maxPartialSuffix(TOOL_CALL_OPENERS);
55+
const partial = maxPartialTagSuffix(this.buffer, TOOL_CALL_OPENERS);
5656
if (partial > 0) {
5757
output += this.buffer.slice(0, this.buffer.length - partial);
5858
this.buffer = this.buffer.slice(this.buffer.length - partial);
@@ -89,18 +89,6 @@ export class ToolCallTokenFilter {
8989
}
9090
return len;
9191
}
92-
93-
private partialSuffix(text: string, tag: string): number {
94-
for (let len = Math.min(tag.length - 1, text.length); len > 0; len--) {
95-
if (text.endsWith(tag.slice(0, len))) return len;
96-
}
97-
return 0;
98-
}
99-
100-
/** Longest suffix of the buffer that is a prefix of ANY tag — hold back a partial tag of any form. */
101-
private maxPartialSuffix(tags: string[]): number {
102-
return tags.reduce((max, tag) => Math.max(max, this.partialSuffix(this.buffer, tag)), 0);
103-
}
10492
}
10593

10694
function parseToolCall(tc: any): ToolCall {

src/services/providers/openAICompatibleStream.ts

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55
import { createNDJSONStreamingRequest } from '../httpClient';
66
import logger from '../../utils/logger';
7-
import { REASONING_DELIMITERS } from '../../utils/messageContent';
7+
import { REASONING_DELIMITERS, partialTagSuffix, maxPartialTagSuffix } from '../../utils/messageContent';
88
import type { StreamCallbacks } from './types';
99
import type {
1010
OpenAIChatMessage,
@@ -50,7 +50,7 @@ export class ThinkTagParser {
5050
}
5151
if (bestIdx === -1) {
5252
// No complete opener. Hold back only a suffix that could still become one of the openers.
53-
const partial = this.maxPartialSuffix(this.buffer, REASONING_DELIMITERS.map((d) => d.open));
53+
const partial = maxPartialTagSuffix(this.buffer, REASONING_DELIMITERS.map((d) => d.open));
5454
if (partial > 0) {
5555
onToken(this.buffer.slice(0, this.buffer.length - partial));
5656
this.buffer = this.buffer.slice(this.buffer.length - partial);
@@ -76,7 +76,7 @@ export class ThinkTagParser {
7676
const closeTag = this.activeClose;
7777
const idx = this.buffer.indexOf(closeTag);
7878
if (idx === -1) {
79-
const partial = this.partialSuffix(this.buffer, closeTag);
79+
const partial = partialTagSuffix(this.buffer, closeTag);
8080
if (partial > 0) {
8181
onReasoning(this.buffer.slice(0, this.buffer.length - partial));
8282
this.buffer = this.buffer.slice(this.buffer.length - partial);
@@ -101,19 +101,6 @@ export class ThinkTagParser {
101101
if (shouldBreak) break;
102102
}
103103
}
104-
105-
/** Length of the longest suffix of text that is a prefix of tag. */
106-
private partialSuffix(text: string, tag: string): number {
107-
for (let len = Math.min(tag.length - 1, text.length); len > 0; len--) {
108-
if (text.endsWith(tag.slice(0, len))) return len;
109-
}
110-
return 0;
111-
}
112-
113-
/** Longest suffix of text that is a prefix of ANY tag — so a partial opener of any format is held back. */
114-
private maxPartialSuffix(text: string, tags: string[]): number {
115-
return tags.reduce((max, tag) => Math.max(max, this.partialSuffix(text, tag)), 0);
116-
}
117104
}
118105

119106
/** Context passed to processDelta */

src/utils/messageContent.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
import type { ParsedContent } from '../components/ChatMessage/types';
1+
/**
2+
* The parsed shape of a model message: reasoning split from the visible response.
3+
* Owned HERE (the util that produces it via parseThinkingContent) so store/service/pro layers
4+
* import it without a backwards dependency on the ChatMessage component; ChatMessage/types
5+
* re-exports it for the UI. (Was imported FROM the component — the wrong direction.)
6+
*/
7+
export interface ParsedContent {
8+
thinking: string | null;
9+
response: string;
10+
isThinkingComplete: boolean;
11+
thinkingLabel?: string;
12+
}
213

314
/**
415
* THE single source of truth for the Gemma-native tool-call delimiter grammar. Both the
@@ -22,6 +33,23 @@ const TOOL_CALL_UNCLOSED_PATTERNS: RegExp[] = TOOL_CALL_OPENERS.map(
2233
(open) => new RegExp(`${escapeRegExp(open)}[\\s\\S]*$`),
2334
);
2435

36+
/**
37+
* Length of the longest suffix of `text` that is a PREFIX of `tag` — i.e. how much of a possibly-
38+
* incomplete tag is dangling at the end of a stream chunk, so the incremental parsers can hold it
39+
* back until the next chunk. Single source shared by ThinkTagParser and ToolCallTokenFilter (both
40+
* had a verbatim copy).
41+
*/
42+
export function partialTagSuffix(text: string, tag: string): number {
43+
for (let len = Math.min(tag.length - 1, text.length); len > 0; len--) {
44+
if (text.endsWith(tag.slice(0, len))) return len;
45+
}
46+
return 0;
47+
}
48+
/** Longest suffix of `text` that is a prefix of ANY tag — hold back a partial opener/closer of any form. */
49+
export function maxPartialTagSuffix(text: string, tags: string[]): number {
50+
return tags.reduce((max, tag) => Math.max(max, partialTagSuffix(text, tag)), 0);
51+
}
52+
2553
const CONTROL_TOKEN_PATTERNS: RegExp[] = [
2654
/<\|im_start\|>\s*(?:system|assistant|user|tool)?\s*\n?/gi,
2755
/<\|im_end\|>\s*\n?/gi,

0 commit comments

Comments
 (0)