From 28fd6f702e5f28e9e4119aef392c59532323515c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maple=EF=BC=81?= Date: Wed, 2 Sep 2026 16:31:49 +0800 Subject: [PATCH] fix: classify eight more harness message shapes found in the subagent layer Re-scanning the last 60 days including /subagents/*.jsonl, which the ADR-008 scan skipped, surfaced entries the parser still rendered as user turns: the relocated entry type, task notifications wrapped in a [SYSTEM NOTIFICATION] disclaimer, coordinator messages, , the "Continue from where you left off." meta prompt, , the no-visible-output nudge, and user text relayed mid-turn by the harness. Each now gets a domain flag, a compact form, a role label, and a CountsAsTurn() verdict under the existing ADR-008 decisions; the mid-turn relay keeps the user: label because its body is human-typed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TcvWErnKnuADJsomnZfwFr --- docs/adr-008-harness-event-drift.md | 25 ++++ internal/analyzer/stats.go | 4 +- internal/claudecodec/classify.go | 97 ++++++++++++-- internal/claudecodec/harness_classify_test.go | 120 ++++++++++++++++++ internal/claudecodec/reader.go | 8 +- internal/formatter/harness_role_test.go | 68 ++++++++++ internal/formatter/render.go | 18 +++ internal/session/compact.go | 27 ++++ internal/session/event.go | 53 +++++++- internal/session/turn_test.go | 53 ++++++++ 10 files changed, 459 insertions(+), 14 deletions(-) diff --git a/docs/adr-008-harness-event-drift.md b/docs/adr-008-harness-event-drift.md index 6cee320..f778ee6 100644 --- a/docs/adr-008-harness-event-drift.md +++ b/docs/adr-008-harness-event-drift.md @@ -16,6 +16,31 @@ Reader 靠比對字面字串認出 Claude Code 寫進 transcript 的事件。那 量測基礎:`~/.claude/projects` 下依修改時間取最新 120 個 `.jsonl`(2026-08-30 往前 60 天)。 +**追加樣本(2026-09-02)**:原始盤點跳過了 `/subagents/*.jsonl` 這一層, +這次先把最大的 120 個 subagent transcript 併入盤點,找到五個沿用既有決定即可處理的樣本: + +- `relocated` entry type(600 筆/82 KB),併入第 1 項的白名單。 +- `` 前面多了一段「[SYSTEM NOTIFICATION - NOT USER INPUT]」免責聲明 + (68 則,CLI 2.1.200–2.1.235,全在 subagent transcript),偵測改成認標籤本身而非開頭前綴, + 沿用第 4 項認標籤不認散文的理由。 +- `The coordinator sent a message while you were working:`(3 則),coordinator 對 subagent + 發起一輪工作,比照第 2、5 項歸類為 harness、算 turn。 +- ``(2 則),跟已處理的 stdout 變體同一種病,補上同樣的處理。 +- `Continue from where you left off.`(2 則,帶頂層 `isMeta: true`), + 是別處已啟動的 invocation 的尾巴,比照 Stop hook 通知不算 turn。 + +再把盤點範圍擴大到主 session 與 subagent 兩層合併掃描,又找到三個: + +- ``…``(5 則,全在 subagent transcript,約 1,000 字元), + worker fork 的固定開場白,壓成 `[fork]`,閉合標籤後的內文(fork 的實際指令)保留;算 turn, + 因為它啟動了這個 fork 的工作。 +- `[Your previous response had no visible output. Please continue and produce a user-visible response.]` + (36 則,精確文字,主 session),壓成 `[nudge: no visible output]`;算 turn, + 因為它是要求新一輪回應,不是在報告已經發生的事。 +- `The user sent a new message while you were working:` 開頭、接一段解釋文字收尾(35 則), + 內文本身是人打的字,所以維持 `user:` 標籤,只剝掉開頭與結尾的 harness 說明文字; + 不算 turn,因為 harness 自己的說明就寫著這則訊息落在正在跑的那一輪裡面,不是另開一輪。 + ## 1. `noiseTypes` 漏了 8 個 CLI 後來才加的 entry type `noiseTypes` 是手寫的白名單,收 13 個型別。實測出現、不在名單裡的有 8 個: diff --git a/internal/analyzer/stats.go b/internal/analyzer/stats.go index 9b3b320..e1187f4 100644 --- a/internal/analyzer/stats.go +++ b/internal/analyzer/stats.go @@ -117,7 +117,9 @@ func ComputeStats(events []session.Event) StatsResult { if event.User.IsSkillInjection || event.User.IsTeammateMessage || event.User.IsCommandInjection || event.User.IsTaskNotification || event.User.IsCompactionSummary || event.User.IsStopHookGoal || - event.User.IsAgentsStopped || event.User.IsInterrupted { + event.User.IsAgentsStopped || event.User.IsInterrupted || + event.User.IsCoordinatorMessage || event.User.IsContinuePrompt || + event.User.IsForkBoilerplate || event.User.IsNoVisibleOutputNudge { rawParts = append(rawParts, event.User.Text) continue } diff --git a/internal/claudecodec/classify.go b/internal/claudecodec/classify.go index cc47bdc..9bf5dd2 100644 --- a/internal/claudecodec/classify.go +++ b/internal/claudecodec/classify.go @@ -18,6 +18,7 @@ const ( tagBashInputOpen = "" tagBashInputClose = "" tagLocalStdout = "" + tagLocalStderr = "" tagBashStdout = "" tagBashStderr = "" tagLocalCaveat = "" @@ -37,11 +38,33 @@ const ( commandMessageOpen = "" skillArgsPrefix = "ARGUMENTS:" - taskNotificationOpen = "" - compactionSummary = "This session is being continued from a previous conversation" - interruptedPrefix = "[Request interrupted by user" - stopHookPrefix = "A session-scoped Stop hook is now active with condition:" - skillReloadedMarker = "was loaded earlier" + taskNotificationOpen = "" + compactionSummary = "This session is being continued from a previous conversation" + interruptedPrefix = "[Request interrupted by user" + stopHookPrefix = "A session-scoped Stop hook is now active with condition:" + skillReloadedMarker = "was loaded earlier" + coordinatorMessageOpen = "The coordinator sent a message while you were working:" + + // continuePromptText is the exact harness-injected body that resumes an + // invocation already started elsewhere. It carries no sourceToolUseID + // link the way a skill injection does — only isMeta at the top level — + // so it is matched on exact text rather than a prefix, the same way the + // stop-hook goal is matched by its fixed wording. + continuePromptText = "Continue from where you left off." + + forkBoilerplateOpen = "" + forkBoilerplateClose = "" + + // noVisibleOutputNudge is the exact harness nudge sent when an assistant + // turn produced no visible output. + noVisibleOutputNudge = "[Your previous response had no visible output. Please continue and produce a user-visible response.]" + + // midTurnOpeningLine and midTurnExplanationMarker bracket the human text + // in a mid-turn message notice: the harness wraps a message the user sent + // while the agent was still working in an explanation of when it arrives, + // but the body between them is exactly what the user typed. + midTurnOpeningLine = "The user sent a new message while you were working:" + midTurnExplanationMarker = "This is how Claude Code surfaces messages the user sends mid-turn" ) var agentsStoppedCount = regexp.MustCompile(`^(\d+) background agents? (?:was|were) stopped`) @@ -82,9 +105,11 @@ func classifyCommandUserMessage(text string) *session.UserMessage { } } - // Command output (slash stdout, bash stdout/stderr): droppable body, - // surfaced only under -verbose-commands with ANSI stripped at render time. + // Command output (slash stdout/stderr, bash stdout/stderr): droppable + // body, surfaced only under -verbose-commands with ANSI stripped at + // render time. if strings.HasPrefix(trimmed, tagLocalStdout) || + strings.HasPrefix(trimmed, tagLocalStderr) || strings.HasPrefix(trimmed, tagBashStdout) || strings.HasPrefix(trimmed, tagBashStderr) { return &session.UserMessage{IsCommandNoise: true, Text: trimmed} @@ -121,6 +146,17 @@ func classifySkillInjectionByLink(text string, isMeta bool, sourceToolUseID stri } } +// classifyContinuePrompt detects the exact-text isMeta continuation prompt +// that resumes an invocation already started elsewhere. isMeta alone is not +// a marker (image placeholders and stop-hook feedback also carry it, see +// classifySkillInjectionByLink), so this also requires the exact text. +func classifyContinuePrompt(text string, isMeta bool) *session.UserMessage { + if !isMeta || strings.TrimSpace(text) != continuePromptText { + return nil + } + return &session.UserMessage{Text: text, IsContinuePrompt: true} +} + // classifyHarnessUserMessage detects harness-injected user messages that are // not direct user input: skill injections, system reminders, teammate messages, // context usage blocks, and command injection XML. Returns nil for plain @@ -145,6 +181,16 @@ func classifyHarnessUserMessage(text string) *session.UserMessage { } } + // Mid-turn user message: the body is genuinely what the user typed, sent + // while the agent was still working on the previous turn, so it falls + // back to plain user-message handling for everything except the + // wrapper text stripped here. + if strings.HasPrefix(trimmed, midTurnOpeningLine) { + if body, ok := extractMidTurnUserText(trimmed); ok { + return &session.UserMessage{Text: text, IsMidTurnUserMessage: true, MidTurnUserText: body} + } + } + // 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 @@ -171,10 +217,32 @@ func classifyHarnessUserMessage(text string) *session.UserMessage { // Background-task report. Recognized here rather than only at render time // so stats sees a domain field like every other subtype (ADR-008). - if strings.HasPrefix(trimmed, taskNotificationOpen) { + // Matched by Contains, not HasPrefix: a CLI build wraps the tag in a + // "[SYSTEM NOTIFICATION - NOT USER INPUT]" disclaimer that precedes it, + // the same drift the teammate tag detection already accounts for. + if strings.Contains(trimmed, taskNotificationOpen) { return &session.UserMessage{Text: text, IsTaskNotification: true} } + // Coordinator-initiated round of work in a subagent transcript: the + // coordinator's own message starts a turn the same way a teammate + // message does, so it gets the same treatment. + if strings.HasPrefix(trimmed, coordinatorMessageOpen) { + return &session.UserMessage{Text: text, IsCoordinatorMessage: true} + } + + // Worker-fork preamble: the harness's instructions for a forked worker, + // followed by the actual directive text (if any) after the closing tag. + if strings.HasPrefix(trimmed, forkBoilerplateOpen) { + return &session.UserMessage{Text: text, IsForkBoilerplate: true} + } + + // Fixed nudge sent when the previous assistant turn had no visible + // output. Exact text, so no extraction needed. + if trimmed == noVisibleOutputNudge { + return &session.UserMessage{Text: text, IsNoVisibleOutputNudge: true} + } + // Conversation summary injected when a session continues past a // compaction. The body is the previous conversation, so it is kept. if strings.HasPrefix(trimmed, compactionSummary) { @@ -230,6 +298,19 @@ func extractGoalCondition(text string) string { return rest[:end] } +// extractMidTurnUserText pulls the human-typed body out of a mid-turn user +// message notice, stripping the opening line and the trailing explanation +// paragraph. Returns ("", false) if the explanation marker is absent, so the +// caller does not classify a message whose shape it can't fully account for. +func extractMidTurnUserText(text string) (string, bool) { + rest := strings.TrimPrefix(text, midTurnOpeningLine) + explIdx := strings.Index(rest, midTurnExplanationMarker) + if explIdx < 0 { + return "", false + } + return strings.TrimSpace(rest[:explIdx]), true +} + // reloadedSkillName extracts the skill from a "Skill /foo was loaded earlier" // notice, or reports false when text is not one. func reloadedSkillName(text string) (string, bool) { diff --git a/internal/claudecodec/harness_classify_test.go b/internal/claudecodec/harness_classify_test.go index a20ba2b..cb2380c 100644 --- a/internal/claudecodec/harness_classify_test.go +++ b/internal/claudecodec/harness_classify_test.go @@ -152,6 +152,123 @@ func TestClassifyHarnessUserMessage_GivenPlainMessage_WhenClassified_ThenReturns } } +// The disclaimer wraps the tag in a CLI build seen only in subagent +// transcripts (68 messages, CLI 2.1.200-2.1.235); HasPrefix missed all of +// them since the tag no longer opens the message. +func TestClassifyHarnessUserMessage_GivenTaskNotificationWrappedInDisclaimer_WhenClassified_ThenStillDetectsIt(t *testing.T) { + text := "[SYSTEM NOTIFICATION - NOT USER INPUT]\n" + + "This is an automated background-task event, NOT a message from the user.\n" + + "Do NOT interpret this as user acknowledgement, confirmation, or response to any pending question.\n\n" + + "\na3d084a486cbf8046\n" + + "Build finished\n" + + got := classifyHarnessUserMessage(text) + + if got == nil || !got.IsTaskNotification { + t.Fatalf("classifyHarnessUserMessage() = %+v, want IsTaskNotification = true", got) + } +} + +// 3 messages in subagent transcripts: the coordinator opens a round of work +// the same way a teammate message does, but with different framing. +func TestClassifyHarnessUserMessage_GivenCoordinatorMessage_WhenClassified_ThenMarksIt(t *testing.T) { + text := "The coordinator sent a message while you were working:\nplease also update the README" + + got := classifyHarnessUserMessage(text) + + if got == nil || !got.IsCoordinatorMessage { + t.Fatalf("classifyHarnessUserMessage() = %+v, want IsCoordinatorMessage = true", got) + } +} + +// classifyContinuePrompt requires the isMeta flag in addition to the exact +// text, since isMeta alone also covers image placeholders and stop-hook +// feedback (see classifySkillInjectionByLink). +func TestClassifyContinuePrompt_GivenExactTextAndIsMeta_WhenClassified_ThenMarksIt(t *testing.T) { + tests := map[string]struct { + text string + isMeta bool + want bool + }{ + "exact text with isMeta true is a continue prompt": { + text: "Continue from where you left off.", isMeta: true, want: true, + }, + "exact text without isMeta is not, since isMeta alone is not distinctive": { + text: "Continue from where you left off.", isMeta: false, want: false, + }, + "isMeta true with different text is not": { + text: "Continue from where you left off, please.", isMeta: true, want: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := classifyContinuePrompt(tc.text, tc.isMeta) + if (got != nil) != tc.want { + t.Errorf("classifyContinuePrompt(%q, %v) = %+v, want non-nil = %v", tc.text, tc.isMeta, got, tc.want) + } + if tc.want && !got.IsContinuePrompt { + t.Errorf("IsContinuePrompt = false, want true") + } + }) + } +} + +// 5 messages, all subagent transcripts: the worker-fork preamble is fixed +// boilerplate, and any text after the closing tag is the fork's directive. +func TestClassifyHarnessUserMessage_GivenForkBoilerplate_WhenClassified_ThenMarksIt(t *testing.T) { + text := "\nYou are a worker fork. The transcript above is the parent's " + + "history. Execute ONE directive, then stop.\n\nFix the failing test." + + got := classifyHarnessUserMessage(text) + + if got == nil || !got.IsForkBoilerplate { + t.Fatalf("classifyHarnessUserMessage() = %+v, want IsForkBoilerplate = true", got) + } +} + +// 36 messages, exact text, main sessions: the harness's nudge to produce a +// visible response after a silent turn. +func TestClassifyHarnessUserMessage_GivenNoVisibleOutputNudge_WhenClassified_ThenMarksIt(t *testing.T) { + text := "[Your previous response had no visible output. Please continue and produce a user-visible response.]" + + got := classifyHarnessUserMessage(text) + + if got == nil || !got.IsNoVisibleOutputNudge { + t.Fatalf("classifyHarnessUserMessage() = %+v, want IsNoVisibleOutputNudge = true", got) + } +} + +// 35 messages: the body is human-typed, so classification must keep it under +// the user role (not harness) while still stripping the harness's wrapper. +func TestClassifyHarnessUserMessage_GivenMidTurnUserMessage_WhenClassified_ThenExtractsTheUserText(t *testing.T) { + text := "The user sent a new message while you were working:\n直接改 bug 就好\n\n" + + "This is how Claude Code surfaces messages the user sends mid-turn — within the running " + + "turn, often alongside the next tool result, rather than as a separate conversation turn. " + + "Address the message above as you continue this turn." + + got := classifyHarnessUserMessage(text) + + if got == nil || !got.IsMidTurnUserMessage { + t.Fatalf("classifyHarnessUserMessage() = %+v, want IsMidTurnUserMessage = true", got) + } + if want := "直接改 bug 就好"; got.MidTurnUserText != want { + t.Errorf("MidTurnUserText = %q, want %q", got.MidTurnUserText, want) + } +} + +// 2 messages render as "user:" because the stderr variant of the +// local-command tag has no handling, unlike its stdout sibling. +func TestClassifyCommandUserMessage_GivenLocalCommandStderr_WhenClassified_ThenMarksItCommandNoise(t *testing.T) { + text := "permission denied" + + got := classifyCommandUserMessage(text) + + if got == nil || !got.IsCommandNoise { + t.Fatalf("classifyCommandUserMessage(%q) = %+v, want IsCommandNoise = true", text, got) + } +} + // These entry types were added to Claude Code after the original noise list. // Without an entry they fell through as unparsed rather than as EventNoise, // which left their bytes out of the analyzer's system_noise accounting. @@ -160,6 +277,9 @@ func TestParseLine_GivenRecentlyAddedEntryType_WhenParsed_ThenYieldsNoise(t *tes "atis-latch", "frame-link", "worktree-state", "file-history-delta", "artifact-autoreact-ledger", "artifact-comment-monitor", "agent-setting", "cost-state", + // Found when the ADR-008 scan was extended to the subagent + // transcript layer: 600 entries / 82 KB in the same 60-day window. + "relocated", } for _, entryType := range types { diff --git a/internal/claudecodec/reader.go b/internal/claudecodec/reader.go index 8cf6f05..ea1a759 100644 --- a/internal/claudecodec/reader.go +++ b/internal/claudecodec/reader.go @@ -47,6 +47,10 @@ var noiseTypes = map[string]bool{ "artifact-comment-monitor": true, "agent-setting": true, "cost-state": true, + + // Added after the ADR-008 scan was extended to the subagent transcript + // layer; observed 600 entries / 82 KB in the same 60-day window. + "relocated": true, } func ReadFile(path string, handle func(session.Event) error) error { @@ -173,7 +177,9 @@ func parseLineWithToolNames(line []byte, toolCalls map[string]toolCallInfo) (ses return session.Event{}, false, nil } event.Kind = session.EventUserMessage - if classified := classifySkillInjectionByLink(text, raw.IsMeta, raw.SourceToolUseID, toolCalls); classified != nil { + if classified := classifyContinuePrompt(text, raw.IsMeta); classified != nil { + event.User = classified + } else if classified := classifySkillInjectionByLink(text, raw.IsMeta, raw.SourceToolUseID, toolCalls); classified != nil { event.User = classified } else if classified := classifyCommandUserMessage(text); classified != nil { event.User = classified diff --git a/internal/formatter/harness_role_test.go b/internal/formatter/harness_role_test.go index 7739f06..60f9086 100644 --- a/internal/formatter/harness_role_test.go +++ b/internal/formatter/harness_role_test.go @@ -40,6 +40,43 @@ func TestFormatReadEvents_GivenHarnessInjection_WhenRendered_ThenLabelsItHarness }, wantBody: "[benchmark done]", }, + // Regression: the disclaimer that wraps the tag in subagent + // transcripts made classification miss it, so it rendered as a plain + // "user:" turn with the disclaimer left in the body. + "a disclaimer-wrapped task notification still keeps its summary": { + user: session.UserMessage{ + IsTaskNotification: true, + Text: "[SYSTEM NOTIFICATION - NOT USER INPUT]\nThis is an automated background-task event.\n\n" + + "\nbenchmark done\n", + }, + wantBody: "[benchmark done]", + }, + "a coordinator message keeps its body under the coordinator marker": { + user: session.UserMessage{ + IsCoordinatorMessage: true, + Text: "The coordinator sent a message while you were working:\nplease also update the README", + }, + wantBody: "[coordinator]\nplease also update the README", + }, + "a continuation prompt collapses to a marker": { + user: session.UserMessage{IsContinuePrompt: true, Text: "Continue from where you left off."}, + wantBody: "[continue]", + }, + "a worker-fork preamble keeps only its directive": { + user: session.UserMessage{ + IsForkBoilerplate: true, + Text: "\nYou are a worker fork. Execute ONE directive, then stop.\n" + + "\nFix the failing test.", + }, + wantBody: "[fork]\nFix the failing test.", + }, + "a no-visible-output nudge collapses to a marker": { + user: session.UserMessage{ + IsNoVisibleOutputNudge: true, + Text: "[Your previous response had no visible output. Please continue and produce a user-visible response.]", + }, + wantBody: "[nudge: no visible output]", + }, } for name, tc := range tests { @@ -63,6 +100,37 @@ func TestFormatReadEvents_GivenHarnessInjection_WhenRendered_ThenLabelsItHarness } } +// Unlike the other harness subtypes, a mid-turn message's body is genuinely +// human-typed, so it must keep the "user:" label with the harness wrapper +// (opening line and timing explanation) gone from the output. +func TestFormatReadEvents_GivenMidTurnUserMessage_WhenRendered_ThenLabelsItUserWithoutTheWrapper(t *testing.T) { + events := []session.Event{{ + Kind: session.EventUserMessage, + Timestamp: "2026-08-30T13:00:00Z", + User: &session.UserMessage{ + IsMidTurnUserMessage: true, + MidTurnUserText: "直接改 bug 就好", + Text: "The user sent a new message while you were working:\n直接改 bug 就好\n\n" + + "This is how Claude Code surfaces messages the user sends mid-turn — within the running " + + "turn, often alongside the next tool result, rather than as a separate conversation turn. " + + "Address the message above as you continue this turn.", + }, + }} + + var out bytes.Buffer + if err := FormatReadEvents(events, nil, 0, 0, FormatOptions{}, &out); err != nil { + t.Fatalf("FormatReadEvents returned error: %v", err) + } + + got := out.String() + if want := "[13:00:00] user:\n直接改 bug 就好"; !strings.Contains(got, want) { + t.Errorf("output missing %q\ngot:\n%s", want, got) + } + if strings.Contains(got, "sent a new message while you were working") || strings.Contains(got, "surfaces messages") { + t.Errorf("output still contains the harness wrapper text\ngot:\n%s", got) + } +} + func TestFormatReadEvents_GivenTypedMessage_WhenRendered_ThenStillLabelsItUser(t *testing.T) { events := []session.Event{ { diff --git a/internal/formatter/render.go b/internal/formatter/render.go index 447c575..539198f 100644 --- a/internal/formatter/render.go +++ b/internal/formatter/render.go @@ -91,6 +91,12 @@ func renderUserMessage(user *session.UserMessage, opts FormatOptions, seenSkills return userRender{body: body, role: RoleHarness, show: true} } + // Mid-turn user message: the body is human-typed, so it keeps the user + // role — only the harness's timing explanation is stripped. + if user.IsMidTurnUserMessage { + return userRender{body: user.MidTurnUserText, role: RoleUser, show: true} + } + // Harness-injected subtypes: strip or compact. if user.IsSystemReminder || user.IsContextUsage { return userRender{} @@ -104,6 +110,15 @@ func renderUserMessage(user *session.UserMessage, opts FormatOptions, seenSkills } return harnessRender(user.Text) } + if user.IsCoordinatorMessage { + return harnessRender(session.CompactCoordinatorMessage(user.Text)) + } + if user.IsForkBoilerplate { + return harnessRender(session.CompactForkBoilerplate(user.Text)) + } + if user.IsNoVisibleOutputNudge { + return harnessRender("[nudge: no visible output]") + } if user.IsCommandInjection { if body, ok := session.CompactCommandInjection(user.Text); ok { return harnessRender(body) @@ -128,6 +143,9 @@ func renderUserMessage(user *session.UserMessage, opts FormatOptions, seenSkills if user.IsInterrupted { return harnessRender("[interrupted]") } + if user.IsContinuePrompt { + return harnessRender("[continue]") + } if strings.TrimSpace(user.Text) == "" { return userRender{} diff --git a/internal/session/compact.go b/internal/session/compact.go index 1395bb8..63ae510 100644 --- a/internal/session/compact.go +++ b/internal/session/compact.go @@ -208,6 +208,33 @@ func extractXMLAttr(tag, attr string) string { return tag[start : start+end] } +// CompactForkBoilerplate renders a worker-fork preamble as "[fork]", keeping +// whatever directive text follows the closing tag (if any) — the boilerplate +// itself is fixed and carries no information beyond "this is a fork". +func CompactForkBoilerplate(text string) string { + const marker = "[fork]" + const closeTag = "" + idx := strings.Index(text, closeTag) + if idx < 0 { + return marker + } + directive := strings.TrimSpace(text[idx+len(closeTag):]) + if directive == "" { + return marker + } + return marker + "\n" + directive +} + +// CompactCoordinatorMessage renders a coordinator-to-subagent message as +// "[coordinator]\n", stripping the fixed opening line the same way +// CompactTeammateMessage strips the teammate warning boilerplate. +func CompactCoordinatorMessage(text string) string { + const marker = "[coordinator]" + const openingLine = "The coordinator sent a message while you were working:" + body := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(text), openingLine)) + return marker + "\n" + body +} + // CompactCommandInjection extracts the command name and args from a // // XML block into a single line. func CompactCommandInjection(text string) (string, bool) { diff --git a/internal/session/event.go b/internal/session/event.go index 64f6705..b9d107f 100644 --- a/internal/session/event.go +++ b/internal/session/event.go @@ -86,6 +86,12 @@ type UserMessage struct { // IsTeammateMessage marks a teammate-message with harness warning boilerplate. IsTeammateMessage bool + // IsCoordinatorMessage marks a "The coordinator sent a message while you + // were working:" notice in a subagent transcript. Like a teammate + // message, it starts a round of work rather than describing one already + // underway. + IsCoordinatorMessage bool + // IsCommandInjection marks a / XML block // that precedes a skill injection (distinct from the existing CommandMarker // which covers slash-commands detected via at line start). @@ -116,6 +122,28 @@ type UserMessage struct { // notice that is not fixed boilerplate. IsStopHookGoal bool GoalCondition string + + // IsContinuePrompt marks the exact-text "Continue from where you left + // off." isMeta message. It is the tail of an invocation already started + // elsewhere, the same reasoning as the Stop hook notice. + IsContinuePrompt bool + + // IsForkBoilerplate marks the harness preamble sent to a worker fork + // ("..."), possibly followed by the + // fork's actual directive after the closing tag. + IsForkBoilerplate bool + + // IsNoVisibleOutputNudge marks the fixed harness nudge sent when the + // previous assistant turn produced no visible output. + IsNoVisibleOutputNudge bool + + // IsMidTurnUserMessage marks a message the user typed while the agent + // was still working on the previous turn. Unlike the other harness + // subtypes above, the body is genuinely human-typed — the harness only + // wraps it in an explanation of when it arrives — so it renders under + // the user role. MidTurnUserText is that body with the wrapper stripped. + IsMidTurnUserMessage bool + MidTurnUserText string } // CountsAsTurn reports whether this message starts a unit of agent work: an @@ -133,14 +161,29 @@ type UserMessage struct { // The kinds that return false despite preceding an API call are the ones // that arrive as the tail of an invocation something else already started: // skill and command injections, the Stop hook notice that follows a /goal, -// and the notice that a skill was re-invoked. Counting those would count one -// turn twice. CommandMarker is false for the same reason; ADR-008's open +// the notice that a skill was re-invoked, and the exact-text continuation +// prompt that resumes a background invocation. Counting those would count +// one turn twice. CommandMarker is false for the same reason; ADR-008's open // questions cover attributing a slash command's turn to one of its entries. +// +// IsCoordinatorMessage and IsForkBoilerplate count by the same reasoning as a +// teammate message — each is another Claude session (a coordinator, or the +// parent that forked a worker) starting a round of work — rather than by the +// same by-observation test: both are too rare in the sample (3 and 5 +// messages) to measure what follows them reliably. IsNoVisibleOutputNudge +// counts because it is a nudge to keep working, not a report of it: it does +// not describe a round already underway. +// +// IsMidTurnUserMessage is false: the harness's own wording says the message +// "arrives ... within the running turn," so it does not start a new one — +// same reasoning as the injections that arrive alongside the turn that +// triggered them. func (u UserMessage) CountsAsTurn() bool { if u.CommandMarker != "" { return false } - if u.IsTeammateMessage || u.IsTaskNotification || u.IsCompactionSummary { + if u.IsTeammateMessage || u.IsTaskNotification || u.IsCompactionSummary || + u.IsCoordinatorMessage || u.IsForkBoilerplate || u.IsNoVisibleOutputNudge { return true } return !u.IsCommandNoise && @@ -151,7 +194,9 @@ func (u UserMessage) CountsAsTurn() bool { !u.IsSystemReminder && !u.IsInterrupted && !u.IsAgentsStopped && - !u.IsStopHookGoal + !u.IsStopHookGoal && + !u.IsContinuePrompt && + !u.IsMidTurnUserMessage } type Usage struct { diff --git a/internal/session/turn_test.go b/internal/session/turn_test.go index d0ccee9..5083a12 100644 --- a/internal/session/turn_test.go +++ b/internal/session/turn_test.go @@ -64,6 +64,26 @@ func TestCountsAsTurn_GivenMessageKind_WhenCounted_ThenFollowsWorkUnitPolicy(t * message: UserMessage{CommandMarker: "[/goal]"}, want: false, }, + "a coordinator message is a turn: it starts a round of work like a teammate message": { + message: UserMessage{Text: "…", IsCoordinatorMessage: true}, + want: true, + }, + "a continuation prompt is not: it is the tail of an invocation already started elsewhere": { + message: UserMessage{Text: "Continue from where you left off.", IsContinuePrompt: true}, + want: false, + }, + "a worker-fork preamble is a turn: it starts the fork's work": { + message: UserMessage{Text: "…", IsForkBoilerplate: true}, + want: true, + }, + "a no-visible-output nudge is a turn: it asks for a new response, not a report of one": { + message: UserMessage{Text: "…", IsNoVisibleOutputNudge: true}, + want: true, + }, + "a mid-turn user message is not: the harness says it arrives within the running turn": { + message: UserMessage{Text: "…", IsMidTurnUserMessage: true, MidTurnUserText: "改一下這裡"}, + want: false, + }, } for name, tc := range tests { @@ -97,6 +117,39 @@ func TestCompactStopHookGoal_GivenNoCondition_WhenCompacted_ThenKeepsTheNotice(t } } +func TestCompactCoordinatorMessage_GivenNotice_WhenCompacted_ThenStripsTheOpeningLine(t *testing.T) { + text := "The coordinator sent a message while you were working:\nplease also update the README" + + got := CompactCoordinatorMessage(text) + + if want := "[coordinator]\nplease also update the README"; got != want { + t.Errorf("CompactCoordinatorMessage() = %q, want %q", got, want) + } +} + +func TestCompactForkBoilerplate_GivenPreambleWithDirective_WhenCompacted_ThenKeepsOnlyTheDirective(t *testing.T) { + text := "\nYou are a worker fork. Execute ONE directive, then stop.\n" + + "\nFix the failing test." + + got := CompactForkBoilerplate(text) + + if want := "[fork]\nFix the failing test."; got != want { + t.Errorf("CompactForkBoilerplate() = %q, want %q", got, want) + } +} + +// A fork preamble with no directive after the closing tag still leaves +// something to read: the marker on its own. +func TestCompactForkBoilerplate_GivenPreambleWithNoDirective_WhenCompacted_ThenKeepsOnlyTheMarker(t *testing.T) { + text := "\nYou are a worker fork.\n" + + got := CompactForkBoilerplate(text) + + if want := "[fork]"; got != want { + t.Errorf("CompactForkBoilerplate() = %q, want %q", got, want) + } +} + func TestCompactAgentsStopped_GivenNotice_WhenCompacted_ThenKeepsOnlyTheCount(t *testing.T) { user := &UserMessage{IsAgentsStopped: true, StoppedAgentCount: 7}