Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions docs/adr-008-harness-event-drift.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 不再全文渲染 |

Expand Down Expand Up @@ -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 則用 `<agent-message>` 標籤而非
`<teammate-message>`,開場白和免責聲明都沒有附上,兩個標記都落空。
80 則裡有 3 則(35 個樣本中)連屬性都沒有。

改成只認 XML 標籤本身,不再看散文。開場白、免責聲明都是 harness 生成給人看的
說明文字,harness 會重寫;標籤是 harness 自己要解析的結構,重寫成本高得多。
兩種標籤都收:`<teammate-message>`(發送端 id 放在 `teammate_id`)與
`<agent-message>`(放在 `from`,可能整個不帶屬性)。標籤清單收在
`session.TeammateTagVariants`,`classify.go` 和 `CompactTeammateMessage`
共用同一份,避免兩處各自列一次而漂移。下次改字只有改到標籤名稱才會讓偵測失效。

`CompactTeammateMessage` 裡剝除警告的那段同理也是死碼,但無害:
抽取迴圈只取 `<teammate-message>` 標籤之間的內容,尾巴的警告本來就進不來。這次沒動它。
抽取迴圈只取標籤之間的內容,尾巴的警告本來就進不來。這次沒動它。

## 5. 一個 flag 同時回答了兩個不相干的問題

Expand Down Expand Up @@ -221,11 +230,6 @@ teammate message 沒有這種欄位(頂層欄位與一般 user 訊息完全相

## 沒有解決的

**teammate 的 `<agent-message>` 變體沒認**。60 天內 1,112 則 teammate message 有 80 則
用 `<agent-message>` 而不是 `<teammate-message>` 包,目前全文渲染、算成一般 user turn。
修法很小(兩個標籤都收),但 teammate 偵測整體要不要改成只認標籤、不看散文,
一起留到下次決定。

**slash / bang command 不算 turn**,維持改動前的行為。
`/goal` 這類 invocation 會觸發一整輪工作,照第 5 項的定義應該算;
但它在 transcript 裡是三筆 entry(`<command-name>` marker、`<command-message>` 注入、
Expand Down
38 changes: 7 additions & 31 deletions internal/claudecodec/classify.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ const bangCommandMarkerMaxRunes = 80
const (
skillInjectionPrefix = "Base directory for this skill:"
systemReminderOpen = "<system-reminder>"
teammateOpen = "<teammate-message"
teammatePrefix = "Another Claude session sent a message:"
contextUsageHeader = "## Context Usage"
contextUsageMarker = "Estimated usage by category"
commandMessageOpen = "<command-message>"
Expand All @@ -46,17 +44,6 @@ const (
skillReloadedMarker = "was loaded earlier"
)

// teammateWarnings are the harness disclaimers appended to a teammate message.
// The wording has changed once already: the first form matched 0 of the 133
// teammate messages in the 60 days before 2026-08-30, and detection survived
// only because teammatePrefix happened to match all 133. Both are kept so a
// transcript written by either harness version classifies, and so the next
// rewording degrades to a miss on one marker rather than on all of them.
var teammateWarnings = []string{
"IMPORTANT: This is NOT from your user",
"This came from another Claude session",
}

var agentsStoppedCount = regexp.MustCompile(`^(\d+) background agents? (?:was|were) stopped`)

// classifyCommandUserMessage inspects a user-role message body and returns a
Expand Down Expand Up @@ -158,10 +145,13 @@ func classifyHarnessUserMessage(text string) *session.UserMessage {
}
}

// Teammate message: the XML block plus either the opening line or one of
// the harness disclaimers. Two independent markers because the disclaimer
// wording has already changed once (see teammateWarnings).
if strings.Contains(trimmed, teammateOpen) && hasTeammateMarker(trimmed) {
// Teammate message: detected by the XML tag alone, not by the surrounding
// prose ("Another Claude session sent a message:", the disclaimer). Both
// have already been reworded once without the tag changing, and the
// harness has also been observed to skip the opening line and disclaimer
// entirely. session.TeammateTagVariants covers every tag shape seen so
// far; a rewording only breaks detection if it changes the tag itself.
if session.HasTeammateMessageTag(trimmed) {
return &session.UserMessage{Text: text, IsTeammateMessage: true}
}

Expand Down Expand Up @@ -222,20 +212,6 @@ func classifyHarnessUserMessage(text string) *session.UserMessage {
return nil
}

// hasTeammateMarker reports whether text carries any of the harness markers
// that identify a teammate message.
func hasTeammateMarker(text string) bool {
if strings.HasPrefix(text, teammatePrefix) {
return true
}
for _, warning := range teammateWarnings {
if strings.Contains(text, warning) {
return true
}
}
return false
}

// extractGoalCondition pulls the goal out of a Stop hook notice. The rest of
// the notice describes how the hook behaves and is identical every time, so
// the condition is the only part worth keeping. Returns "" when the quoted
Expand Down
27 changes: 18 additions & 9 deletions internal/claudecodec/harness_classify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,19 +102,25 @@ func TestClassifyHarnessUserMessage_GivenHarnessInjection_WhenClassified_ThenSet
}
}

// The first disclaimer wording matched 0 of the 133 teammate messages observed
// in the 60 days before 2026-08-30 — the harness had reworded it, and detection
// survived only because the opening line happened to match every one. Each
// marker must classify on its own so a single rewording cannot break detection.
func TestClassifyHarnessUserMessage_GivenTeammateMarkerVariant_WhenClassified_ThenStillDetectsIt(t *testing.T) {
const block = "<teammate-message teammate_id=\"rebase-389\" summary=\"done\">\nRebase 完成。\n</teammate-message>"
// 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 <agent-message> tag with
// neither the opening line nor a disclaimer at all.
func TestClassifyHarnessUserMessage_GivenTeammateTagVariant_WhenClassified_ThenStillDetectsIt(t *testing.T) {
const teammateBlock = "<teammate-message teammate_id=\"rebase-389\" summary=\"done\">\nRebase 完成。\n</teammate-message>"
const agentBlock = "<agent-message from=\"cdcv13-finish\">\n工作完成。\n</agent-message>"

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: <agent-message> 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": "<agent-message>\n工作完成。\n</agent-message>",
}

for name, text := range tests {
Expand All @@ -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 {
Expand Down
85 changes: 71 additions & 14 deletions internal/session/compact.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
// `<teammate-message>` is the original form; `<agent-message>` 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: "<teammate-message", Close: "</teammate-message>", IDAttr: "teammate_id"},
{Open: "<agent-message", Close: "</agent-message>", 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, "<teammate-message") {
if !HasTeammateMessageTag(text) {
return "", false
}

Expand All @@ -87,12 +139,12 @@ func CompactTeammateMessage(text string) (string, bool) {
text = text[:idx]
}

// May contain multiple <teammate-message> blocks.
// May contain multiple teammate-message blocks, possibly mixing variants.
var parts []string
remaining := text
for {
openIdx := strings.Index(remaining, "<teammate-message")
if openIdx < 0 {
variant, openIdx := nextTeammateTagMatch(remaining)
if variant == nil {
break
}
// Extract attributes from the opening tag.
Expand All @@ -101,30 +153,35 @@ func CompactTeammateMessage(text string) (string, bool) {
break
}
openingTag := remaining[openIdx : openIdx+tagEnd+1]
tmID := extractXMLAttr(openingTag, "teammate_id")
id := extractXMLAttr(openingTag, variant.IDAttr)
summary := extractXMLAttr(openingTag, "summary")

// Extract body between > and </teammate-message>.
// Extract body between the opening tag and its matching close tag.
bodyStart := openIdx + tagEnd + 1
closeTag := "</teammate-message>"
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 <agent-message>
// 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
Expand Down
38 changes: 38 additions & 0 deletions internal/session/event_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,44 @@ func TestCompactTeammateMessage_GivenNonTeammate_ThenReturnsFalse(t *testing.T)
}
}

// Regression: <agent-message> 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 <teammate-message> 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 := `<agent-message from="cdcv13-finish">
Rebase 完成。
</agent-message>`

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 <agent-message> 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 := `<agent-message>
狀態更新。
</agent-message>`

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) {
Expand Down
Loading