Skip to content

Commit cb59e37

Browse files
authored
fix(chat): 修复历史回放时代码块末行越界崩溃 (#15)
Root cause of the recurring "StringIndexOutOfBoundsException: begin 0, end 376, length 375" during retryLast/runAgentLoop. fenceSegments in the reasoning-tag history stripper built the closing-fence range with `until lineEnd + 1`. When the closing fence is the LAST line and the text has no trailing newline, lineEnd == text.length, so the range's last index became text.length — and the downstream text.substring(range) in transformProtectingCode asked for end = length + 1 (note: end is exactly length+1 in the report, not a byte/char mixup). Coerce the upper bound to the text end. The loop it created: the turn crashed during effectiveAgentHistory()'s per-message cleanup, the exception text was persisted as an inline error, and replaying that same assistant message on the next retry hit the same out-of-bounds slice again. A DeepSeek/QwQ reply whose closing ``` sits on the final line with no trailing newline triggers it reliably. Also add a defensive per-transformer fallback in MessageTransformerChain: a cosmetic cleanup that throws must neither kill the agent loop nor leave its own exception text persisted as turn content — it now logs the offending shape and replays the untransformed text. Regression test: fence closing on the final line without a trailing newline, including one whose body looks like a reasoning tag. Co-authored-by: tall-1997 <278631348+tall-1997@users.noreply.github.com>
1 parent a507a88 commit cb59e37

2 files changed

Lines changed: 49 additions & 2 deletions

File tree

‎src/android/app/src/main/java/com/openminis/app/agent/MessageTransformers.kt‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,28 @@ object MessageTransformerChain {
4848
)
4949

5050
/** Clean an assistant reply before it is replayed to the model. */
51-
fun apply(text: String): String = transformers.fold(text) { t, tr -> tr.transform(t) }
51+
fun apply(text: String): String = transformers.fold(text) { t, tr ->
52+
// A transformer that throws on a pathological assistant turn (the
53+
// reported StringIndexOutOfBoundsException — begin 0, end N, length
54+
// N-1 — surfaces here, inside ReasoningTagStripper's code-fence /
55+
// inline-code masking) must not kill the whole agent loop, and must
56+
// not leave the exception's own message persisted as the turn's
57+
// content (which is what made the crash recur on every replay of that
58+
// message). Fall back to the text as it stood BEFORE this transformer
59+
// and log the offending shape so the real boundary bug can be found:
60+
// the transform is a cosmetic cleanup, the untransformed text is
61+
// always safe to send.
62+
try {
63+
tr.transform(t)
64+
} catch (e: Exception) {
65+
com.openminis.app.logging.AppLogger.warning(
66+
"MsgTransform",
67+
"transformer ${tr.id} threw ${e.javaClass.simpleName}: ${e.message} " +
68+
"on text.length=${t.length} head=\"${t.take(120)}\" — using untransformed text",
69+
)
70+
t
71+
}
72+
}
5273

5374
/**
5475
* Enrich a user message before send. Returns the original text when no
@@ -193,7 +214,18 @@ object MessageTransformerChain {
193214
} else if (inFence && trimmed.startsWith(fenceMark) &&
194215
trimmed.length == fenceMark.length
195216
) {
196-
out += segStart until lineEnd + 1 to true
217+
// The close fence is the last line with no trailing
218+
// newline: nl < 0 makes lineEnd == text.length, and
219+
// `until lineEnd + 1` would set range.last = text.length,
220+
// so the downstream text.substring(range) asks for
221+
// end = length + 1 → StringIndexOutOfBoundsException
222+
// (the reported "begin 0, end 376, length 375"; an
223+
// assistant turn whose closing ``` sits on the final
224+
// line without a trailing newline — DeepSeek/QwQ do
225+
// this). Coerce the upper bound to the text end: with a
226+
// real newline we keep it (lineEnd + 1 == nl + 1), without
227+
// one there is nothing past lineEnd to include anyway.
228+
out += segStart until (lineEnd + 1).coerceAtMost(text.length) to true
197229
segStart = lineEnd + 1
198230
inFence = false
199231
}

‎src/android/app/src/test/java/com/openminis/app/agent/ReasoningTagStripperCoverageTest.kt‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,19 @@ class ReasoningTagStripperCoverageTest {
7777
val text = "before\n```\n<think>demo <b>bold"
7878
assertEquals(text, apply(text))
7979
}
80+
81+
@Test
82+
fun `fence closing on the final line without trailing newline does not crash`() {
83+
// Regression for StringIndexOutOfBoundsException "begin 0, end N,
84+
// length N-1": when the closing fence is the last line and the text
85+
// has no trailing newline, fenceSegments produced a range with
86+
// last = text.length, and substring(range) asked for end = length+1.
87+
// DeepSeek/QwQ replies end exactly like this.
88+
val fenced = "body\n```kotlin\nval x = 1\n```"
89+
assertEquals(fenced, apply(fenced))
90+
// Same shape but the closing fence shares content that looks like a
91+
// reasoning tag — the crash hit before the tag logic ever ran.
92+
val withTag = "```\n<think>demo</think>\n```"
93+
assertEquals(withTag, apply(withTag))
94+
}
8095
}

0 commit comments

Comments
 (0)