Skip to content

fix(coding-agent): keep the reply language after compaction and long tool output - #4540

Open
code-yeongyu wants to merge 6 commits into
mainfrom
fix/11527-reply-language-after-compaction
Open

code-yeongyu wants to merge 6 commits into
mainfrom
fix/11527-reply-language-after-compaction

Conversation

@code-yeongyu

@code-yeongyu code-yeongyu commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

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): readReplyLanguage reads 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). insertReplyLanguageNotes adds 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: convertToLlm applies the notes. A compaction summary with a userLanguage carries 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 sets userLanguage on 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 as followerMessage and reused by the note.
  • packages/agent/src/harness/messages.ts: CompactionSummaryMessage gains the optional userLanguage (the AgentMessage union 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 convertToLlm exactly as each provider request is built. No live-model run is claimed.
  • npx tsc --noEmit (root): exit 0.
  • oxfmt --check and oxlint --deny-warnings on 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 on main in this checkout because it needs a built dist.

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

  • CompactionSummaryMessage gains optional userLanguage and userLanguageRequested fields.

Written for commit 57349b3. Summary will update on new commits.

View guided diff Turn on auto-fix

Review folds (head 701a198)

  • Explicit request wins and survives compaction. readReplyLanguageRequest reads 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 as userLanguage + 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 repeated convertToLlm calls 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_NAMES is 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 6bbe85a)

  • 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.

  • Quoted spans no longer decide the written language (user-prose.ts, ownLines).

  • Cache. No caller edits user content in place. The in-place content.push calls are on streaming assistant output only: anthropic-subscription/stream-events.ts:28, anthropic-subscription/stream.ts:263 and cursor-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 counts readReplyLanguage calls (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 8f96d94)

  • 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.

…st and read only the user's own prose

Review folds for senpi#4540 (omo#11527): an explicit reply-language request wins and survives
compaction; the tag strip is linear and each message's reading is cached; a note can only name
a fixed language; no note follows a run whose result carries the user's words; pasted, quoted
and log lines no longer decide the language.
…own directive

Review fold for senpi#4540 (omo#11527): the request reader is an allow-list of whole directive
sentences on the user's own line; quoted, attributed, speaker-prefixed and pasted text never counts.
Quoted spans no longer decide the written language. The reading cache also keys on the content's
shape, and the linear-scan and cache tests are deterministic.
Review fold for senpi#4540 (omo#11527): a request counts only in a short message (3 lines, 300
chars) whose first or last line is the directive and where nothing marks pasted or reported
content (colon-terminated labels, speaker prefixes, quote or blockquote marks, forwarded or
original-message headers, attribution verbs). The reading cache keys on an FNV-1a content hash.
…message

Review fold for senpi#4540 (omo#11527): a request needs the whole message to be one line of at
most 120 characters that is the directive, with no quote, speaker prefix, dash attribution or
attribution verb (now also 말씀하셨, 요청하셨, 그러시, mentioned). Any multi-line message has no
request by design; every multi-line form from the earlier reviews is a regression case.

This branch has not been deployed

No deployments
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.

[Bug]: Agent keeps replying in English to a Korean-speaking user (drifts after compaction / tool-heavy turns)

1 participant