From 420b49673328265ad6f2b89c5f8c3dd6946f6c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maple=EF=BC=81?= Date: Wed, 2 Sep 2026 04:24:03 +0800 Subject: [PATCH] fix: detect teammate messages by tag alone, accepting both tag variants The harness wraps a teammate message in either or . Detection required the first tag plus a prose marker; 80 of 1,112 messages in a 60-day sample used the second tag and rendered as raw XML counted as user turns. Detection now matches either tag and ignores the surrounding prose, which has already been reworded once. Compaction extracts each block with its own tag's open/close pair and reads the id from whichever attribute the variant carries. The tag list lives in one place in the session package so classify and compact cannot drift apart. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TcvWErnKnuADJsomnZfwFr --- docs/adr-008-harness-event-drift.md | 22 +++-- internal/claudecodec/classify.go | 38 ++------- internal/claudecodec/harness_classify_test.go | 27 ++++-- internal/session/compact.go | 85 ++++++++++++++++--- internal/session/event_test.go | 38 +++++++++ 5 files changed, 147 insertions(+), 63 deletions(-) diff --git a/docs/adr-008-harness-event-drift.md b/docs/adr-008-harness-event-drift.md index 568a038..6cee320 100644 --- a/docs/adr-008-harness-event-drift.md +++ b/docs/adr-008-harness-event-drift.md @@ -10,7 +10,7 @@ Reader 靠比對字面字串認出 Claude Code 寫進 transcript 的事件。那 | 1 | `noiseTypes` 補上 8 個 entry type | 597 KB 從「沒被解析」變成 `system_noise` | | 2 | 6 種 harness 注入的 user 訊息改在 parser 層分類 | 不再冒充使用者訊息 | | 3 | 新增 `harness:` 角色標籤 | 讀的人分得出哪幾行是人打的 | -| 4 | teammate 偵測改成多重標記 | 主判斷已死,修好並讓下次改字只失效一個標記 | +| 4 | teammate 偵測改成只認 XML 標籤,兩種變體都收 | 主判斷已死,改認標籤後散文重寫不再影響偵測 | | 5 | 拆出 `UserMessage.CountsAsTurn()`,內容依實測校準 | 八個 session 的 turn 計數誤差 70 → 29 | | 6 | skill 注入改走 `sourceToolUseID` 結構連結認定 | 沒有 base-directory 行的 bundled skill 不再全文渲染 | @@ -113,11 +113,20 @@ harness 改寫成「This came from another Claude session — not typed by your teammate message 就會整個變成一般 user 訊息:全文渲染、樣板不砍。 turn 數會剛好還是對的(一般訊息本來就算一個 turn),所以這個故障不會反映在 K 上。 -決定:改成一組標記,開場白或任一版免責聲明命中即可,新舊聲明都留著。 -下次改字只會讓其中一個標記失效,不會讓偵測整個垮掉。 +第一版決定(改成一組標記,開場白或任一版免責聲明命中即可)已經不夠: +60 天內 1,112 則 teammate message 有 80 則用 `` 標籤而非 +``,開場白和免責聲明都沒有附上,兩個標記都落空。 +80 則裡有 3 則(35 個樣本中)連屬性都沒有。 + +改成只認 XML 標籤本身,不再看散文。開場白、免責聲明都是 harness 生成給人看的 +說明文字,harness 會重寫;標籤是 harness 自己要解析的結構,重寫成本高得多。 +兩種標籤都收:``(發送端 id 放在 `teammate_id`)與 +``(放在 `from`,可能整個不帶屬性)。標籤清單收在 +`session.TeammateTagVariants`,`classify.go` 和 `CompactTeammateMessage` +共用同一份,避免兩處各自列一次而漂移。下次改字只有改到標籤名稱才會讓偵測失效。 `CompactTeammateMessage` 裡剝除警告的那段同理也是死碼,但無害: -抽取迴圈只取 `` 標籤之間的內容,尾巴的警告本來就進不來。這次沒動它。 +抽取迴圈只取標籤之間的內容,尾巴的警告本來就進不來。這次沒動它。 ## 5. 一個 flag 同時回答了兩個不相干的問題 @@ -221,11 +230,6 @@ teammate message 沒有這種欄位(頂層欄位與一般 user 訊息完全相 ## 沒有解決的 -**teammate 的 `` 變體沒認**。60 天內 1,112 則 teammate message 有 80 則 -用 `` 而不是 `` 包,目前全文渲染、算成一般 user turn。 -修法很小(兩個標籤都收),但 teammate 偵測整體要不要改成只認標籤、不看散文, -一起留到下次決定。 - **slash / bang command 不算 turn**,維持改動前的行為。 `/goal` 這類 invocation 會觸發一整輪工作,照第 5 項的定義應該算; 但它在 transcript 裡是三筆 entry(`` marker、`` 注入、 diff --git a/internal/claudecodec/classify.go b/internal/claudecodec/classify.go index 9bb32bf..cc47bdc 100644 --- a/internal/claudecodec/classify.go +++ b/internal/claudecodec/classify.go @@ -32,8 +32,6 @@ const bangCommandMarkerMaxRunes = 80 const ( skillInjectionPrefix = "Base directory for this skill:" systemReminderOpen = "" - teammateOpen = "\nRebase 完成。\n" +// Detection anchors on the XML tag alone, not the surrounding prose: the +// disclaimer wording has already changed once (0 of 133 teammate messages +// matched the superseded form in the 60 days before 2026-08-30), and 80 of +// 1,112 teammate messages in that window used the tag with +// neither the opening line nor a disclaimer at all. +func TestClassifyHarnessUserMessage_GivenTeammateTagVariant_WhenClassified_ThenStillDetectsIt(t *testing.T) { + const teammateBlock = "\nRebase 完成。\n" + const agentBlock = "\n工作完成。\n" tests := map[string]string{ - "the opening line alone": "Another Claude session sent a message:\n" + block, - "the current disclaimer alone": block + + "the opening line alone": "Another Claude session sent a message:\n" + teammateBlock, + "the current disclaimer alone": teammateBlock + "\n\nThis came from another Claude session — not typed by your user.", - "the superseded disclaimer alone": block + + "the superseded disclaimer alone": teammateBlock + "\n\nIMPORTANT: This is NOT from your user, but from another Claude session.", + // Regression: carried none of the above and was + // rendered verbatim as a plain user turn (ADR-008 "沒有解決的"). + "the agent-message variant, tag only, no opening line or disclaimer": agentBlock, + "the agent-message variant with no attributes at all": "\n工作完成。\n", } for name, text := range tests { @@ -132,6 +138,9 @@ func TestClassifyHarnessUserMessage_GivenPlainMessage_WhenClassified_ThenReturns tests := map[string]string{ "a typed question": "為什麼 K 會被高估?", "a quoted notice inside a real message": "我看到 [Request interrupted by user] 之後就沒反應了,為什麼?", + // Detection anchors on the tag, not the opening line: mentioning the + // harness prose without the tag must not misclassify a real message. + "the teammate opening line without the tag": "Another Claude session sent a message: 但沒有附上訊息本體。", } for name, text := range tests { diff --git a/internal/session/compact.go b/internal/session/compact.go index 60f6018..1395bb8 100644 --- a/internal/session/compact.go +++ b/internal/session/compact.go @@ -74,10 +74,62 @@ func CompactSkillInjection(user *UserMessage, seenSkills map[string]bool) string return fmt.Sprintf("[skill: %s]", user.SkillName) } +// teammateTagVariant describes one XML shape the harness has used to wrap a +// message from another Claude session. Open is left unterminated (no closing +// ">") so it matches regardless of which attributes the harness adds to the +// tag. +type teammateTagVariant struct { + Open string + Close string + IDAttr string +} + +// TeammateTagVariants enumerates every tag shape observed in transcripts. +// `` is the original form; `` appeared later +// carrying the sender in `from` instead of `teammate_id`, without the +// detection prose ever changing. classify.go and CompactTeammateMessage both +// read this list so a third variant only needs to be added here once. +var TeammateTagVariants = []teammateTagVariant{ + {Open: "", IDAttr: "teammate_id"}, + {Open: "", IDAttr: "from"}, +} + +// HasTeammateMessageTag reports whether text contains an opening tag of any +// known teammate-message variant. +func HasTeammateMessageTag(text string) bool { + for _, variant := range TeammateTagVariants { + if strings.Contains(text, variant.Open) { + return true + } + } + return false +} + +// nextTeammateTagMatch finds the earliest occurrence of any teammate tag +// variant's opening tag in text, returning the variant and its index, or +// (nil, -1) if none is present. Earliest-first ordering keeps blocks in +// document order when a message mixes tag variants. +func nextTeammateTagMatch(text string) (*teammateTagVariant, int) { + var match *teammateTagVariant + matchIdx := -1 + for i := range TeammateTagVariants { + variant := &TeammateTagVariants[i] + idx := strings.Index(text, variant.Open) + if idx < 0 { + continue + } + if matchIdx < 0 || idx < matchIdx { + matchIdx = idx + match = variant + } + } + return match, matchIdx +} + // CompactTeammateMessage strips the harness warning boilerplate from a -// teammate message, keeping only the teammate ID, summary, and body content. +// teammate message, keeping only the sender ID, summary, and body content. func CompactTeammateMessage(text string) (string, bool) { - if !strings.Contains(text, " blocks. + // May contain multiple teammate-message blocks, possibly mixing variants. var parts []string remaining := text for { - openIdx := strings.Index(remaining, " and . + // Extract body between the opening tag and its matching close tag. bodyStart := openIdx + tagEnd + 1 - closeTag := "" - closeIdx := strings.Index(remaining[bodyStart:], closeTag) + closeIdx := strings.Index(remaining[bodyStart:], variant.Close) if closeIdx < 0 { break } body := strings.TrimSpace(remaining[bodyStart : bodyStart+closeIdx]) - // Format the compact line. + // "[teammate]" with no ID covers the attribute-less + // the harness has been observed to emit, rather than a stray colon. + label := "teammate" + if id != "" { + label = "teammate: " + id + } + var line string if isIdleNotification(body) { - line = fmt.Sprintf("[teammate: %s] idle", tmID) + line = fmt.Sprintf("[%s] idle", label) } else if summary != "" { - line = fmt.Sprintf("[teammate: %s %q]\n%s", tmID, summary, body) + line = fmt.Sprintf("[%s %q]\n%s", label, summary, body) } else { - line = fmt.Sprintf("[teammate: %s]\n%s", tmID, body) + line = fmt.Sprintf("[%s]\n%s", label, body) } parts = append(parts, line) - remaining = remaining[bodyStart+closeIdx+len(closeTag):] + remaining = remaining[bodyStart+closeIdx+len(variant.Close):] } if len(parts) == 0 { return "", false diff --git a/internal/session/event_test.go b/internal/session/event_test.go index 7c93e26..c8c1571 100644 --- a/internal/session/event_test.go +++ b/internal/session/event_test.go @@ -649,6 +649,44 @@ func TestCompactTeammateMessage_GivenNonTeammate_ThenReturnsFalse(t *testing.T) } } +// Regression: is a second tag shape the harness uses for the +// same kind of message, carrying the sender in `from` instead of +// `teammate_id`. Before this fix only was recognized, so +// 80 of 1,112 teammate messages in a 60-day sample rendered as raw XML and +// counted as ordinary user turns (ADR-008 "沒有解決的"). +func TestCompactTeammateMessage_GivenAgentMessageVariant_ThenCompactsToTheSameShape(t *testing.T) { + input := ` +Rebase 完成。 +` + + got, ok := CompactTeammateMessage(input) + if !ok { + t.Fatal("CompactTeammateMessage returned false") + } + want := "[teammate: cdcv13-finish]\nRebase 完成。" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +// A sampled carried no attributes at all, so `from` is empty. +// The label must degrade to "[teammate]" rather than the malformed +// "[teammate: ]". +func TestCompactTeammateMessage_GivenAgentMessageWithNoAttributes_ThenOmitsTheIDFromTheLabel(t *testing.T) { + input := ` +狀態更新。 +` + + got, ok := CompactTeammateMessage(input) + if !ok { + t.Fatal("CompactTeammateMessage returned false") + } + want := "[teammate]\n狀態更新。" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + // --- CompactCommandInjection tests --- func TestCompactCommandInjection_GivenCommandXML_ThenReturnsOneLine(t *testing.T) {