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) {