refactor: centralize thinking tag parsing

Cycle: 1/5

Work item: .agent/work/2026-05-08-2054-text-message-boundaries

Validation: targeted runtime text tests and typecheck passed
This commit is contained in:
George Pickett
2026-05-08 20:59:34 -07:00
parent 7dee5ec52b
commit b2dd992244
8 changed files with 489 additions and 51 deletions
@@ -0,0 +1,153 @@
# Refactor Candidates: Text And Runtime Message Boundaries
## Scope And Constraints
Target repo: `/Users/georgepickett/openclaw-studio`.
Hard constraints:
- Improve OpenClaw Studio only; do not modify `~/openclaw`.
- Current branch is `main` at `7dee5ec52bdb71ada1696fef4c968ee4e35ddca7`, ahead of `origin/main` by 1.
- The worktree has broad pre-existing edits across runtime/control-plane, routes, UI state, and tests. This cycle should avoid those files unless the selected refactor proves the diff can still be isolated.
- Do not push, deploy, create branches, switch branches, touch secrets, or run destructive cleanup.
## First-Principles Repo Model
OpenClaw Studio is a Next/React frontend plus a server-owned gateway/control-plane layer. The browser talks to Studio routes and SSE; Studio owns the gateway WebSocket and persists runtime projection state. Core flows:
1. Runtime events enter through SSE or history hydration, then move through `runtimeEventBridge`, `gatewayRuntimeEventHandler`, and transcript helpers.
2. Chat and transcript rendering depend on text extraction, metadata parsing, thinking extraction, and tool-call formatting.
3. Runtime/intents routes proxy control-plane reads and mutations through server-side modules.
4. Agent settings and creation flows assemble gateway config and local agent files.
5. Tests are broad but many central files are large, so small boundary improvements should happen where tests can pin behavior tightly.
Light evidence:
- `src/lib/text/message-extract.ts` is 552 lines and test-covered by `tests/unit/messageExtract.test.ts`, `tests/unit/extractThinking.test.ts`, `tests/unit/chatItems.test.ts`, and runtime event tests.
- `src/lib/text/media-markdown.ts` is a small isolated parser with focused tests, but it embeds fence scanning and media-line normalization in one loop.
- `src/features/agents/components/AgentInspectPanels.tsx` is 1475 lines, clean in the worktree, and mixes settings panels, cron controls, and personality parsing UI.
- Runtime/control-plane files have stronger architectural payoff but are already dirty, making isolated commits risky in this cycle.
## Ranked Shortlist
1. Split thinking-tag extraction out of `message-extract.ts`.
2. Consolidate media-line scanning into a small parser inside `media-markdown.ts`.
3. Extract a focused subview from `AgentInspectPanels.tsx`.
4. Minimal surgical change: add missing edge-case tests around message extraction with no production refactor.
5. Do nothing.
## Candidate 1: Split Thinking-Tag Extraction
Refactor class: deepen a module by moving one cohesive internal policy out of a mixed utility file.
Scope: `src/lib/text/message-extract.ts`, new `src/lib/text/thinking-tags.ts`, `tests/unit/extractThinking.test.ts`, `tests/unit/messageExtract.test.ts`.
Problem: `message-extract.ts` owns envelope stripping, assistant prefix stripping, text extraction, thinking extraction, tool markdown, meta markdown, and UI metadata stripping. Thinking-tag parsing has its own tag grammar and streaming behavior but is embedded beside unrelated formatting policy.
Supporting evidence: constants and functions for `THINKING_*` are clustered, exported helpers already form a coherent public surface, and tests already isolate thinking behavior.
Contradictory evidence: moving code can create a shallow module if the new file merely re-exports helpers without hiding policy. The current single file keeps message formatting knowledge in one place.
Falsifier: if callers need to import multiple modules to do normal message extraction, the refactor made the interface worse.
Expected payoff: lower cognitive load in `message-extract.ts` while preserving its public API; thinking grammar becomes easier to change without touching tool/meta formatting.
Blast radius: low if exports remain from `message-extract.ts` and tests stay green.
Reversibility: high.
Cheapest probe: inspect imports and verify only tests import the thinking helpers directly.
## Candidate 2: Consolidate Media-Line Scanning
Refactor class: hide sequencing inside a focused parser loop.
Scope: `src/lib/text/media-markdown.ts`, `tests/unit/mediaMarkdown.test.ts`.
Problem: `rewriteMediaLinesToMarkdown` mixes fence state, two-line `MEDIA:` detection, image-path policy, output rendering, and index mutation in one loop.
Supporting evidence: the file is small and isolated; tests cover direct media lines, next-line paths, and fenced blocks.
Contradictory evidence: the current code is short and readable, so extraction may create more concepts than it removes.
Falsifier: if the helper API is larger than the current loop logic, do nothing.
Expected payoff: modest but very safe; easier to add more media kinds later.
Blast radius: very low.
Reversibility: high.
Cheapest probe: inspect whether expected future media variants exist in routes/tests.
## Candidate 3: Extract Agent Inspect Subview
Refactor class: reduce UI cognitive load by keeping related UI state together.
Scope: `src/features/agents/components/AgentInspectPanels.tsx` plus focused component tests.
Problem: a 1475-line component mixes multiple panels and several workflows, making local reasoning expensive.
Supporting evidence: file size and references to personality parsing, cron controls, and settings are large enough to hide unrelated knowledge together.
Contradictory evidence: extracting UI subviews can become shallow prop plumbing, and the file may intentionally keep panel state local.
Falsifier: if extraction requires passing many props or duplicating state, it is not a complexity win.
Expected payoff: medium.
Blast radius: medium, with visual/regression risk.
Reversibility: medium.
Cheapest probe: inspect component boundaries and prop needs before planning.
## Candidate 4: Minimal Surgical Change
Refactor class: test-only characterization.
Scope: `tests/unit/messageExtract.test.ts` or `tests/unit/extractThinking.test.ts`.
Problem: message parsing is central and subtle; edge cases can be pinned before refactoring.
Supporting evidence: tests already exist and are cheap to extend.
Contradictory evidence: tests alone do not remove complexity.
Falsifier: if current tests already cover the selected behavior, added tests are redundant.
Expected payoff: low to medium.
Blast radius: very low.
Reversibility: high.
Cheapest probe: compare current test coverage with helper branches.
## Candidate 5: Do Nothing
Refactor class: avoid churn.
Scope: no changes.
Problem solved: avoids adding churn on top of a broad dirty worktree.
Supporting evidence: many high-value files are already dirty, and isolated commits matter for this goal.
Contradictory evidence: clean, test-covered text modules offer a low-risk improvement.
Falsifier: if the selected candidate can be isolated and validated, doing nothing is too conservative.
Expected payoff: none.
Blast radius: none.
Reversibility: immediate.
Cheapest probe: inspect candidate 1 imports and tests.
## Provisional Leader
Candidate 1 is the provisional leader because it improves a central clean file with focused tests and a small public-surface-preserving move. Candidate 2 is the safest runner-up but may be too small to justify a full cycle. Candidate 3 may be valuable but has more UI regression risk. Candidate 4 is useful only if production refactoring proves too risky. Candidate 5 remains alive because the dirty worktree is broad.
## Next Step
Run `select-refactor` against this work item. Challenge whether Candidate 1 is a real information-hiding improvement or just file shuffling, and verify the import/test surface before locking the decision.
@@ -0,0 +1,52 @@
# Decision: Centralize Thinking Tag Parsing
## Chosen Refactor
Centralize thinking-tag parsing in a dedicated text helper while preserving the existing public imports from `message-extract.ts`. The implementation should move the tag grammar and stream helpers out of the mixed message extraction module and reuse the same helper from runtime agent stream handling.
## Why This Beats The Alternatives
This wins because the selected policy is cohesive, clean in the current worktree, and already covered by focused tests. It is not just file shuffling: `src/features/agents/state/runtimeAgentEventWorkflow.ts` currently duplicates the same thinking-tag open/close regex logic that `src/lib/text/message-extract.ts` owns. A small shared helper can hide that grammar in one place while keeping callers on the existing `message-extract.ts` API where that is the natural high-level interface.
The media-line parser alternative is safer but likely too small; the current `media-markdown.ts` loop is only 80 lines and readable. The UI subview extraction from `AgentInspectPanels.tsx` may be valuable, but it risks shallow prop plumbing and visual regression. Test-only characterization is useful if implementation gets risky, but it does not remove complexity. Do nothing is too conservative because this boundary is clean and isolated from the broad dirty runtime/control-plane work.
## Evidence That Changed Confidence
- `message-extract.ts` contains unrelated concerns: user envelope stripping, assistant text cleanup, thinking extraction, tool markdown, meta markdown, and UI metadata cleanup.
- `extractThinkingFromTaggedText` and `extractThinkingFromTaggedStream` already form a cohesive helper surface with dedicated tests in `tests/unit/extractThinking.test.ts`.
- `runtimeAgentEventWorkflow.ts` has a local `hasUnclosedThinkingTag` helper using duplicated thinking-tag regexes.
- No production caller needs to import a new module for normal extraction if `message-extract.ts` continues to re-export the same public helpers.
- Target files are not part of the pre-existing dirty worktree, so the final diff should be isolatable.
## Runner-Up Outcomes
- Media-line scanning loses because its current implementation is short enough that added helpers may add concepts.
- Agent inspect subview extraction loses because it needs a separate UI-focused pass and could spread state through props.
- Minimal surgical tests lose because they do not deliver a complexity dividend on their own.
- Do nothing loses because the chosen refactor has low blast radius and clear duplicated policy.
## Success Criteria
- Thinking tag open/close grammar lives in one helper module.
- `message-extract.ts` remains the public compatibility boundary for existing imports.
- `runtimeAgentEventWorkflow.ts` uses the shared helper instead of local duplicated regex logic.
- Existing thinking extraction, assistant stream, chat rendering, and runtime event tests still pass.
- The diff touches only this work item plus the selected text/runtime workflow files and tests.
## First Safe Slice
Create `src/lib/text/thinking-tags.ts` with `extractThinkingFromTaggedText`, `extractThinkingFromTaggedStream`, and `hasUnclosedThinkingTag`. Re-export the two existing public extraction helpers from `message-extract.ts`, then replace the local workflow helper with the shared import.
## Abandonment Conditions
- The new module forces broad import churn outside the selected files.
- Tests reveal that message extraction and runtime stream handling intentionally use different tag grammars.
- The current dirty worktree overlaps the selected files before implementation starts.
- Validation failures point to behavior changes outside the intended tag parsing boundary.
## Hard Constraints For ExecPlan
- Do not modify `~/openclaw`.
- Do not change public imports for existing callers unless the plan proves it is necessary.
- Keep the helper deep: the tag grammar and last-open/last-close sequencing should be internal to the helper module.
- Preserve all current behavior unless a test exposes a clear bug and the plan records the decision.
@@ -0,0 +1,187 @@
# Centralize Thinking Tag Parsing
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
This plan follows `.agent/PLANS.md` in this repository.
## Purpose / Big Picture
OpenClaw Studio receives assistant messages from several runtime paths. Some messages include hidden thinking or analysis text wrapped in tags such as `<thinking>...</thinking>` or streaming chunks such as `<think>partial`. Today that tag grammar is split across `src/lib/text/message-extract.ts` and `src/features/agents/state/runtimeAgentEventWorkflow.ts`. After this refactor, the grammar for recognizing thinking tags will live in one helper module, while existing callers can keep importing from `message-extract.ts`. The observable behavior should not change: the same tests for thinking extraction, assistant stream handling, and message display should still pass.
## Progress
- [x] (2026-05-09 04:00Z) Created candidate shortlist and locked the decision to centralize thinking-tag parsing.
- [x] (2026-05-09 04:08Z) Improved plan pass 1/3 for factual accuracy: verified target paths, test files, existing helper references, and Vitest filter syntax.
- [x] (2026-05-09 04:11Z) Improved plan pass 2/3 for completeness and sequencing: made the move order explicit and added fallback validation/recovery notes.
- [x] (2026-05-09 04:13Z) Improved plan pass 3/3 for design quality: constrained the helper to hide tag grammar without exporting regexes or adding configuration.
- [x] (2026-05-09 04:17Z) Added `src/lib/text/thinking-tags.ts` with private tag regexes and public extraction/unclosed-tag helpers.
- [x] (2026-05-09 04:17Z) Kept compatibility exports from `message-extract.ts` and removed duplicated local regex logic from `runtimeAgentEventWorkflow.ts`.
- [x] (2026-05-09 04:17Z) Added tests for the shared unclosed-tag helper.
- [x] (2026-05-09 04:58Z) Ran targeted validation: 3 test files passed, 41 tests passed.
- [x] (2026-05-09 04:58Z) Ran `npm run typecheck`; it passed.
- [x] (2026-05-09 04:58Z) Recorded outcomes and final validation.
- [x] (2026-05-09 05:01Z) Review pass 1/4 checked correctness and behavioral regressions; no code issue found.
- [x] (2026-05-09 05:03Z) Review pass 2/4 checked edge cases and added alias coverage for `hasUnclosedThinkingTag`.
- [x] (2026-05-09 05:05Z) Review pass 3/4 checked the simplicity boundary; no extra knobs, exported regexes, or broad import churn were introduced.
- [x] (2026-05-09 05:07Z) Review pass 4/4 checked validation and regression surface; adjacent runtime event tests and typecheck passed.
## Surprises & Discoveries
- Observation: `runtimeAgentEventWorkflow.ts` duplicates the same open/close thinking tag regex policy that `message-extract.ts` already uses for stream extraction.
Evidence: the local `hasUnclosedThinkingTag` helper scans `<think>`, `<thinking>`, `<analysis>`, `<thought>`, and `<antthinking>` tags independently.
- Observation: The targeted Vitest command can accept file filters after `npm run test --`.
Evidence: `npm run test -- --help` reports Vitest's positional `...filters` usage.
## Decision Log
- Decision: Preserve `message-extract.ts` as the public import boundary for existing callers.
Rationale: Many UI and runtime files already import text, thinking, meta, and tool helpers from that module. Moving public imports broadly would increase churn without improving the interface.
Date/Author: 2026-05-09 / Codex
- Decision: Create a focused `src/lib/text/thinking-tags.ts` helper for the tag grammar.
Rationale: The tag names and last-open/last-close sequencing are one hidden policy. Putting them in one helper removes duplicate regex knowledge while keeping the implementation small.
Date/Author: 2026-05-09 / Codex
- Decision: Do not make the tag set configurable in this refactor.
Rationale: A knob would push policy back onto callers and make the interface easier to misuse. The known tag set is an internal Studio parsing policy and should stay inside the helper.
Date/Author: 2026-05-09 / Codex
## Outcomes & Retrospective
Implemented. The thinking tag grammar and stream-open detection now live in `src/lib/text/thinking-tags.ts`, with private regex policy and a small public helper surface. `src/lib/text/message-extract.ts` remains the compatibility boundary for existing text helper imports. `src/features/agents/state/runtimeAgentEventWorkflow.ts` no longer carries a duplicated local `hasUnclosedThinkingTag` regex implementation. Targeted unit tests and typecheck passed.
The complexity dividend is small but real: the tag names and last-open/last-close sequencing now have one owner, so future changes to supported hidden-thinking tags do not require coordinating separate regex copies across text extraction and runtime stream planning.
## Context and Orientation
The relevant files are all inside OpenClaw Studio; do not modify `~/openclaw`.
`src/lib/text/message-extract.ts` is a mixed text utility module. It extracts visible text from runtime message objects, strips hidden thinking blocks from assistant-visible text, extracts thinking traces, formats thinking traces for transcript output, formats tool call/result markdown, parses metadata markdown, and strips UI-only metadata from user prompts. The phrase "thinking tag grammar" in this plan means the exact tag names and matching rules used to decide which text is hidden reasoning: `think`, `thinking`, `analysis`, `thought`, and `antthinking`, with optional closing tags.
`src/features/agents/state/runtimeAgentEventWorkflow.ts` plans how runtime agent stream events update local agent state. It currently has a local `hasUnclosedThinkingTag` function so that a streaming assistant chunk like `<thinking>planning` is treated as hidden reasoning rather than visible answer text.
`tests/unit/extractThinking.test.ts` covers thinking extraction and formatting helpers. `tests/unit/runtimeAgentEventWorkflow.test.ts` covers streaming event behavior, including open thinking chunks. `tests/unit/messageExtract.test.ts` covers assistant-visible text cleanup.
## Plan of Work
First, add `src/lib/text/thinking-tags.ts`. This module should own the regexes for thinking tag names and export three functions:
extractThinkingFromTaggedText(text: string): string
extractThinkingFromTaggedStream(text: string): string
hasUnclosedThinkingTag(text: string): boolean
Keep the implementation deep: callers should not know the regexes, tag set, or last-open/last-close comparison. They should only ask for extracted thinking text or whether a stream currently has an unclosed thinking tag. Do not export regex constants, tag-name arrays, parser options, or callbacks.
Second, update `src/lib/text/message-extract.ts` to import `extractThinkingFromTaggedText`, `extractThinkingFromTaggedStream`, and `hasUnclosedThinkingTag` from the new module. Re-export all three helpers from `message-extract.ts` so existing imports keep working and runtime code can continue treating `message-extract.ts` as the text boundary. Remove the old inline helper implementations and regex constant that become redundant. Keep `stripThinkingTagsFromAssistantText` behavior unchanged unless tests prove a bug.
Third, update `src/features/agents/state/runtimeAgentEventWorkflow.ts` to import `hasUnclosedThinkingTag` from `message-extract.ts`. Delete the local duplicate `hasUnclosedThinkingTag`.
Fourth, update tests. Add focused assertions in `tests/unit/extractThinking.test.ts` for `hasUnclosedThinkingTag`, covering an open tag, a closed tag, and a later close before a later open. Existing runtime workflow tests should continue to prove that open thinking chunks do not leak into visible assistant text.
The implementation order matters. Add the helper module first, then add compatibility exports from `message-extract.ts`, then switch `runtimeAgentEventWorkflow.ts`. This keeps TypeScript import errors localized: if the workflow import fails, the compatibility boundary is the first place to inspect.
## Concrete Steps
Work from `/Users/georgepickett/openclaw-studio`.
1. Confirm the selected files are not pre-existing dirty:
git status --short -- src/lib/text/message-extract.ts src/features/agents/state/runtimeAgentEventWorkflow.ts tests/unit/extractThinking.test.ts
Expected result before editing: no output for those paths.
2. Add `src/lib/text/thinking-tags.ts` with the shared helper functions.
3. Update `src/lib/text/message-extract.ts` to use and re-export the helper functions.
4. Update `src/features/agents/state/runtimeAgentEventWorkflow.ts` to remove the local duplicated helper.
5. Update `tests/unit/extractThinking.test.ts` with direct coverage for the unclosed-tag helper.
6. Run targeted validation:
npm run test -- tests/unit/extractThinking.test.ts tests/unit/messageExtract.test.ts tests/unit/runtimeAgentEventWorkflow.test.ts
7. Run broader validation if targeted tests pass:
npm run typecheck
Actual validation results:
npm run test -- tests/unit/extractThinking.test.ts tests/unit/messageExtract.test.ts tests/unit/runtimeAgentEventWorkflow.test.ts
Test Files 3 passed (3)
Tests 41 passed (41)
npm run test -- tests/unit/extractThinking.test.ts tests/unit/messageExtract.test.ts tests/unit/runtimeAgentEventWorkflow.test.ts
Test Files 3 passed (3)
Tests 42 passed (42)
npm run test -- tests/unit/extractThinking.test.ts tests/unit/messageExtract.test.ts tests/unit/runtimeAgentEventWorkflow.test.ts tests/unit/runtimeEventBridge.test.ts tests/unit/runtimeChatEventWorkflow.test.ts
Test Files 5 passed (5)
Tests 76 passed (76)
npm run typecheck
Passed with exit code 0.
If targeted validation fails, do not continue to broader validation until the failure is understood. If the failure is in `extractThinking.test.ts`, inspect the new helper behavior first. If it is in `runtimeAgentEventWorkflow.test.ts`, compare the previous local `hasUnclosedThinkingTag` logic against the new helper and preserve old behavior unless a test clearly describes a bug.
## Validation and Acceptance
Acceptance requires:
- `extractThinkingFromTaggedText` and `extractThinkingFromTaggedStream` still behave as before through the existing `message-extract.ts` imports.
- `hasUnclosedThinkingTag` is covered by unit tests and replaces the duplicated workflow-local regex logic.
- Runtime agent workflow tests still show that open thinking chunks update thinking trace without leaking into visible stream text.
- `npm run test -- tests/unit/extractThinking.test.ts tests/unit/messageExtract.test.ts tests/unit/runtimeAgentEventWorkflow.test.ts` passes.
- `npm run typecheck` passes, unless it fails for an environmental or pre-existing reason unrelated to this diff and that reason is documented.
The implementation is not accepted if it only creates a new module but leaves the duplicated `hasUnclosedThinkingTag` regex in `runtimeAgentEventWorkflow.ts`; removing that duplication is the core complexity dividend.
## Idempotence and Recovery
The edits are additive plus small deletions. Re-running tests is safe. If the helper split causes broad import churn or behavior differences outside thinking tag parsing, revert only this cycle's selected source/test files plus `src/lib/text/thinking-tags.ts` and return to the decision artifact; do not modify unrelated dirty files. If validation fails, inspect the failing assertion before changing behavior because the goal is a no-behavior-change refactor.
## Artifacts and Notes
The current selected source files are clean in the pre-existing dirty worktree. The final diff should be limited to:
- `.agent/work/2026-05-08-2054-text-message-boundaries/*`
- `src/lib/text/thinking-tags.ts`
- `src/lib/text/message-extract.ts`
- `src/features/agents/state/runtimeAgentEventWorkflow.ts`
- `tests/unit/extractThinking.test.ts`
## Interfaces and Dependencies
New module:
src/lib/text/thinking-tags.ts
Required exported functions:
export function extractThinkingFromTaggedText(text: string): string
export function extractThinkingFromTaggedStream(text: string): string
export function hasUnclosedThinkingTag(text: string): boolean
The helper module should keep its regexes and tag list private. No new dependency is needed.
Compatibility exports from `src/lib/text/message-extract.ts` must continue to provide:
extractThinkingFromTaggedText
extractThinkingFromTaggedStream
hasUnclosedThinkingTag
Revision note, 2026-05-09 04:08Z: factual accuracy pass verified paths and Vitest command shape, then tightened the import decision so `runtimeAgentEventWorkflow.ts` imports `hasUnclosedThinkingTag` through the existing `message-extract.ts` boundary.
Revision note, 2026-05-09 04:11Z: completeness and sequencing pass clarified the implementation order, the targeted-test failure triage path, and the acceptance rule that duplicated workflow regex logic must be removed.
Revision note, 2026-05-09 04:13Z: design-quality pass made the module boundary deeper by forbidding exported regex/tag configuration and keeping the known tag policy private.
Revision note, 2026-05-09 04:58Z: implementation completed, targeted validation and typecheck passed, and outcomes were recorded.
Revision note, 2026-05-09 05:03Z: review pass 2 added unit coverage proving `hasUnclosedThinkingTag` honors the same `antthinking` and `thought` aliases as tagged stream extraction.
Revision note, 2026-05-09 05:05Z: review pass 3 confirmed the helper keeps regex/tag policy private and the runtime workflow still imports through `message-extract.ts`.
Revision note, 2026-05-09 05:07Z: review pass 4 expanded adjacent validation to runtime event bridge and chat workflow tests, then reran typecheck successfully.
@@ -0,0 +1,15 @@
{
"id": "2026-05-08-2054-text-message-boundaries",
"slug": "text-message-boundaries",
"title": "Text message boundary refactor",
"created_at": "2026-05-09T03:54:40Z",
"updated_at": "2026-05-09T05:07:00Z",
"stage": "implementation",
"state": "completed",
"artifacts": {
"candidates": "candidates.md",
"decision": "decision.md",
"execplan": "execplan.md",
"review": null
}
}
@@ -24,6 +24,7 @@ import {
extractThinkingFromTaggedStream,
extractToolLines,
formatToolCallMarkdown,
hasUnclosedThinkingTag,
isUiMetadataPrefix,
stripUiMetadata,
} from "@/lib/text/message-extract";
@@ -75,21 +76,6 @@ const extractReasoningBody = (value: string): string | null => {
const normalizeReasoningComparable = (value: string): string =>
normalizeAssistantDisplayText(value).trim().toLowerCase();
const hasUnclosedThinkingTag = (value: string): boolean => {
const openMatches = [
...value.matchAll(/<\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi),
];
if (openMatches.length === 0) return false;
const closeMatches = [
...value.matchAll(/<\s*\/\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi),
];
const lastOpen = openMatches[openMatches.length - 1];
const lastClose = closeMatches[closeMatches.length - 1];
if (!lastOpen) return false;
if (!lastClose) return true;
return (lastClose.index ?? -1) < (lastOpen.index ?? -1);
};
const hasReasoningSignal = ({
rawText,
rawDelta,
+11 -36
View File
@@ -1,3 +1,9 @@
import {
extractThinkingFromTaggedStream,
extractThinkingFromTaggedText,
hasUnclosedThinkingTag,
} from "@/lib/text/thinking-tags";
const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/;
const ENVELOPE_CHANNELS = [
"WebChat",
@@ -23,7 +29,6 @@ const THINKING_CLOSE_RE = /<\s*\/\s*(think(?:ing)?|analysis)\s*>/i;
const THINKING_BLOCK_RE =
/<\s*(think(?:ing)?|analysis)\s*>([\s\S]*?)<\s*\/\s*\1\s*>/gi;
const THINKING_STREAM_TAG_RE = /<\s*(\/?)\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi;
const TRACE_MARKDOWN_PREFIX = "[[trace]]";
const TOOL_CALL_PREFIX = "[[tool]]";
@@ -269,41 +274,11 @@ export const extractThinking = (message: unknown): string | null => {
return openTagged ? openTagged : null;
};
export function extractThinkingFromTaggedText(text: string): string {
if (!text) return "";
let result = "";
let lastIndex = 0;
let inThinking = false;
THINKING_STREAM_TAG_RE.lastIndex = 0;
for (const match of text.matchAll(THINKING_STREAM_TAG_RE)) {
const idx = match.index ?? 0;
if (inThinking) {
result += text.slice(lastIndex, idx);
}
const isClose = match[1] === "/";
inThinking = !isClose;
lastIndex = idx + match[0].length;
}
return result.trim();
}
export function extractThinkingFromTaggedStream(text: string): string {
if (!text) return "";
const closed = extractThinkingFromTaggedText(text);
if (closed) return closed;
const openRe = /<\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi;
const closeRe = /<\s*\/\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi;
const openMatches = [...text.matchAll(openRe)];
if (openMatches.length === 0) return "";
const closeMatches = [...text.matchAll(closeRe)];
const lastOpen = openMatches[openMatches.length - 1];
const lastClose = closeMatches[closeMatches.length - 1];
if (lastClose && (lastClose.index ?? -1) > (lastOpen.index ?? -1)) {
return closed;
}
const start = (lastOpen.index ?? 0) + lastOpen[0].length;
return text.slice(start).trim();
}
export {
extractThinkingFromTaggedStream,
extractThinkingFromTaggedText,
hasUnclosedThinkingTag,
};
export const extractThinkingCached = (message: unknown): string | null => {
if (!message || typeof message !== "object") return extractThinking(message);
+48
View File
@@ -0,0 +1,48 @@
const THINKING_STREAM_TAG_RE =
/<\s*(\/?)\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi;
const THINKING_OPEN_STREAM_TAG_RE =
/<\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi;
const THINKING_CLOSE_STREAM_TAG_RE =
/<\s*\/\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi;
export function extractThinkingFromTaggedText(text: string): string {
if (!text) return "";
let result = "";
let lastIndex = 0;
let inThinking = false;
THINKING_STREAM_TAG_RE.lastIndex = 0;
for (const match of text.matchAll(THINKING_STREAM_TAG_RE)) {
const idx = match.index ?? 0;
if (inThinking) {
result += text.slice(lastIndex, idx);
}
const isClose = match[1] === "/";
inThinking = !isClose;
lastIndex = idx + match[0].length;
}
return result.trim();
}
export function hasUnclosedThinkingTag(text: string): boolean {
if (!text) return false;
const openMatches = [...text.matchAll(THINKING_OPEN_STREAM_TAG_RE)];
if (openMatches.length === 0) return false;
const closeMatches = [...text.matchAll(THINKING_CLOSE_STREAM_TAG_RE)];
const lastOpen = openMatches[openMatches.length - 1];
const lastClose = closeMatches[closeMatches.length - 1];
if (!lastOpen) return false;
if (!lastClose) return true;
return (lastClose.index ?? -1) < (lastOpen.index ?? -1);
}
export function extractThinkingFromTaggedStream(text: string): string {
if (!text) return "";
const closed = extractThinkingFromTaggedText(text);
if (closed) return closed;
if (!hasUnclosedThinkingTag(text)) return "";
const openMatches = [...text.matchAll(THINKING_OPEN_STREAM_TAG_RE)];
const lastOpen = openMatches[openMatches.length - 1];
if (!lastOpen) return "";
const start = (lastOpen.index ?? 0) + lastOpen[0].length;
return text.slice(start).trim();
}
+22
View File
@@ -5,6 +5,7 @@ import {
extractThinkingFromTaggedStream,
extractThinkingFromTaggedText,
formatThinkingMarkdown,
hasUnclosedThinkingTag,
isTraceMarkdown,
stripTraceMarkdown,
} from "@/lib/text/message-extract";
@@ -111,3 +112,24 @@ describe("extractThinkingFromTaggedStream", () => {
expect(extractThinkingFromTaggedStream("Hello <think>Plan A so far")).toBe("Plan A so far");
});
});
describe("hasUnclosedThinkingTag", () => {
it("detects a stream with an open thinking tag", () => {
expect(hasUnclosedThinkingTag("Hello <thinking>Plan A so far")).toBe(true);
});
it("ignores a stream whose latest thinking tag is closed", () => {
expect(hasUnclosedThinkingTag("<thinking>Plan A</thinking>\nAnswer")).toBe(false);
});
it("detects a later open tag even after an earlier closed tag", () => {
expect(
hasUnclosedThinkingTag("<thinking>Plan A</thinking>\nAnswer <analysis>Next")
).toBe(true);
});
it("uses the same hidden-thinking tag aliases as stream extraction", () => {
expect(hasUnclosedThinkingTag("Hello <antthinking>Plan A")).toBe(true);
expect(hasUnclosedThinkingTag("Hello <thought>Plan A</thought>")).toBe(false);
});
});