fix(coding-agent): keep the reply language after compaction and long tool output
Fixes code-yeongyu/oh-my-openagent#11527
Reported by @miny6121-sys. Thanks for the detailed report: the timeline of when the drift happened (after compactions, in tool-heavy turns, and even with a "reply in Korean" memory note in place) pointed straight at the context the model sees after a compaction.
Problem
A Korean-speaking user got English replies once a long session had compacted and in turns dominated by English tool output. Nothing in the context named the user's language at that point:
- The compaction prompt (
extensions/builtin/compaction/prompts.ts:31-94) asks for an English-template summary and has no language field. After a compaction, the user's own Korean turns are gone. What remains is English summary prose, and then English tool output. convertToLlm(src/core/messages.ts) rendered that summary and the tool results as-is. Long English text then sat between the user's last Korean message and every reply, with no language signal near the point of generation.- The only language rule in the Claude presets is one sentence in the Handoff section of the system prompt (
dynamic-prompt/handoff.ts:27). senpi#4107 tracks that prompt-side gap (mid-turn switches and English todo labels) and is left open by this PR.
Measured on a synthetic Korean session (the first regression test below): after a compaction cut in the middle of a tool-heavy turn, the model context on main contains no Korean user text and no text that names the user's language.
Change
src/core/user-language.ts(new):readReplyLanguagereads the language of one user message from its script. It counts words after dropping code, links and tagged blocks such as<environment_context>, and scripts written without spaces count two characters as a word. A non-Latin script makes up at least 30% of the words names the language. Latin-dominant text is not named: English needs no note, and Latin-script languages cannot be told apart safely. A script shared by several languages is named by its script (Cyrillic script).insertReplyLanguageNotesadds one short user-role note naming that language after a tool-result run that is followed by the next assistant message (or the end of the context). The note is added once 4000 characters of tool output, notices, bash output or branch summaries have built up since the user's last message. The user's own message, or words typed into a tool's answer, resets the count.src/core/messages.ts:convertToLlmapplies the notes. A compaction summary with auserLanguagecarries the same note inside its own text block after</summary>, so no two user messages become adjacent.src/core/session-manager.ts: the session projection setsuserLanguageon the newest compaction summary from the latest user message before that compaction. It is derived at projection time, so sessions that were already compacted get it too, and the session file format is unchanged.src/core/tool-result-user-words.ts: the existing provenance rule for the message after a tool result (wordMessage) is exported asfollowerMessageand reused by the note.packages/agent/src/harness/messages.ts:CompactionSummaryMessagegains the optionaluserLanguage(theAgentMessageunion takes the type from here).- Cache: the note is derived only from the messages, at fixed positions, so each request extends the previous one byte for byte. A test pins this.
Tests
test/suite/regressions/11527-reply-language-after-compaction.test.ts (15 cases):
| Case | main + stub module (RED) | this PR |
|---|---|---|
| compacted Korean session: context still names Korean | FAIL expected [] to have a length of 1 | pass |
| long English tool output in a Korean turn: note before the next reply | FAIL expected [] to deeply equal [ Array(1) ] | pass |
| converted output converted again keeps exactly one note | FAIL | pass |
readReplyLanguage table (Korean, Korean mixed with English terms, injected tags stripped, Japanese, Chinese, Cyrillic, English quoting one Korean word, French) | 8 FAIL | pass |
request prefix stays byte-identical as the turn grows; no note for an English user or a short tool result; code-only message reads as none | pass (guards) | pass |
RED was run with the new test and a stub user-language.ts that has the real exported signatures, on unchanged main code, so every failure is an assertion, not an import error. 11 of 15 fail there. The other 4 are guards that pass on both trees.
Verification
- The proof is the unit test above: a synthetic Korean session, compacted with a fixed summary, then rendered by
convertToLlmexactly as each provider request is built. No live-model run is claimed. npx tsc --noEmit(root): exit 0.oxfmt --checkandoxlint --deny-warningson every changed file: clean.- Related suites: every coding-agent test file that touches
convertToLlm,buildSessionContext, the compaction summary or the SDK prompt bridge (117 files, 1025 tests). One malformed-envelope fallback test (test/compaction/required-compaction-deterministic-fallback.test.ts) caught a crash on content that is not an array, which session files can contain. It is fixed: content is read as untrusted. The only remaining failure,issue-3518-open-session-yields, fails identically onmainin this checkout because it needs a builtdist.
Summary by cubic
The coding agent now keeps replies in the user's language after a compaction and after long stretches of English tool output, fixing code-yeongyu/oh-my-openagent#11527. Each request carries a short note naming the reply language once 4000 characters of tool output or summaries have built up, and compaction summaries carry the same note inside their own text.
- Reads the reply language from the user's own prose: code, links, quoted lines, pasted log lines, and injected blocks like
<environment_context>are ignored. - An explicit reply-language request (in Korean or English) wins over the written language and survives compaction; it counts only as a whole one-line directive sentence of at most 120 characters, optionally after a few lead words. Quotes, attribute verbs, speaker prefixes, colon-terminated labels, and forwarded-message headers all disqualify a request, and any multi-line message never has one.
- Notes only follow tool-result runs that end before the next assistant message, so two user messages are never adjacent, and a note can only name fixed language names, never user text.
- The note position is derived from the messages at fixed points, so each provider request extends the previous one byte for byte and the cache prefix stays valid. Message readings are cached and invalidated by any content edit, even one that keeps every length.
- Compacted sessions get the note at projection time, so sessions compacted before this change benefit too; the session file format is unchanged.
Migration
CompactionSummaryMessagegains optionaluserLanguageanduserLanguageRequestedfields.
Review folds (head 701a19843b)
- Explicit request wins and survives compaction.
readReplyLanguageRequestreads requests such as "앞으로 답변은 영어로 해줘", "영어로 답해줘" and "reply in Korean from now on". It skips negated requests ("영어로 답하지 마", "don't reply in English") and other tasks (translate, write the README in English). The note then names the requested language, marked "(the user asked for it)". The summary carries it asuserLanguage+userLanguageRequested. - Linear and cached. The tag strip is now a single pass over the text (
reply-language-reading.ts,withoutTaggedBlocks). Each message's reading is cached per message and content object, so repeatedconvertToLlmcalls do not re-read history. Perf tests cover three inputs over 100 KB (generics paste, unclosed tags, one long tag name) with a 150 ms bound, plus 20 conversions of a session holding a 120 KB paste. - Only fixed names in a note.
REPLY_LANGUAGE_NAMESis the only text a note can carry; a forged "Reply language: (...)" in a user message is ignored. - Strict alternation kept. A run whose result carries the user's words gets no note, so two user messages are never adjacent (senpi#2118).
- The user's own prose decides. Code, tagged blocks, links, quoted lines and pasted log/error lines are dropped, and the first line with two or more words decides. An English user pasting a Korean error gets no note.
Tests: 11527-reply-language-reading.test.ts (36 table cases: written language, explicit request in Korean and English, negation, mixed, pasted/quoted foreign text, code, perf). On the previous head, with a shim exposing its detector under the new module name, 15 of the 36 fail on assertions. 11527-reply-language-after-compaction.test.ts (13 cases: compaction with and without an explicit request, the per-case note table, alternation, prefix stability, re-conversion, cached re-reads). Related suites at this head: 119 files, 1032 passed, 16 skipped, 0 failed. No live model calls.
Review folds, round 2 (head 6bbe85a2b)
-
Requests are an allow-list (
reply-language-request.ts). A request counts only when a whole sentence on the user's own line is the directive, optionally after a few lead words ("앞으로", "please"). Reported speech never counts:- a sentence with any quotation mark (ASCII double or single, curly,
「」,『』,«», backticks); - a speaker prefix ("User:", "민지:", "he wrote:");
- an attribution ("라고", "said", "says");
- a pasted paragraph of more than three lines, or a line over 200 characters.
When in doubt it is not a request.
- a sentence with any quotation mark (ASCII double or single, curly,
-
Quoted spans no longer decide the written language (
user-prose.ts,ownLines). -
Cache. No caller edits user content in place. The in-place
content.pushcalls are on streaming assistant output only:anthropic-subscription/stream-events.ts:28,anthropic-subscription/stream.ts:263andcursor-cli-oauth/stream.ts:182,184. Context edits and the environment fold build new messages. The cache still keys on the content's shape (block count and text lengths), so an in-place edit by a hook is read again; a test covers it. -
Deterministic tests. The tag pass counts the characters it visits (
ProseScan) and the test bounds it at 3x the text length for three adversarial inputs over 100 KB. The cache test countsreadReplyLanguagecalls (one across three conversions, two after an in-place edit). No wall-clock assertions remain.
Tests: 11527-reply-language-request.test.ts enumerates every quote style and attribution form in Korean and English, each with a positive control. On the previous head, with shims for the new module names, 34 assertion failures are real regressions. The 3 step-counter tests also fail there, but only because the shim has no counter. Related suites at this head: 121 files, 1110 passed, 0 failed. No live model calls.
Review folds, round 3 (head 8f96d9441)
-
A request is decided for the whole message (
reply-language-request.ts). It counts only when all three hold:- (a) after fenced code and tagged blocks are removed, the message has at most 3 non-empty lines and 300 characters;
- (b) nothing anywhere marks pasted or reported content: a line ending with
:/:, a speaker prefix, any quote or blockquote mark (which also covers a lazy>continuation), a forwarded, original-message or mail header, or an attribution verb; - (c) a whole sentence on the first or last non-empty line is the directive.
When in doubt there is no request.
-
Cache. The key is a 32-bit FNV-1a hash of the text parts, so an in-place edit that keeps every length is read again; a test covers it.
-
Tests. The request table adds colon-terminated labels (Korean, English, fullwidth), forwarded, original-message, Korean-forwarded and mail headers, a lazy
>continuation, a paste, a long message and a middle-line directive, each in Korean and English. The positive controls are영어로 답해줘,please reply in Korean from now on, and a short message with the directive first, then a question. On the previous head, 21 of these cases fail (every new form plus the same-length cache edit). Related suites at this head: 121 files, 1136 passed, 0 failed. No live model calls.