From fddb376ffdb3313e0937bfff5de6adafcc4d4a44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maple=EF=BC=81?= Date: Wed, 2 Sep 2026 18:05:45 +0800 Subject: [PATCH] feat: resolve message role from the promptSource field (ADR-009) Claude Code >= 2.1.165 writes promptSource on user entries. When it is present it decides the role label: system renders as harness:, sdk as user (sdk):, typed/queued/suggestion_accepted as user:, and any value counts as a turn. String classification still runs to pick the compact form for recognized shapes and remains the only path for entries without the field. A human source beats a harness-shaped body; a system source with an unrecognized shape renders in full under harness:. Absence of the field is not treated as a signal. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TcvWErnKnuADJsomnZfwFr --- SKILL.md | 5 +- docs/adr-009-prompt-source-field.md | 88 ++++++++++++++++ internal/claudecodec/model.go | 6 ++ internal/claudecodec/prompt_source_test.go | 116 +++++++++++++++++++++ internal/claudecodec/reader.go | 8 ++ internal/formatter/context.go | 8 +- internal/formatter/harness_role_test.go | 98 +++++++++++++++++ internal/formatter/render.go | 24 ++++- internal/session/event.go | 44 ++++++++ internal/session/turn_test.go | 20 ++++ 10 files changed, 412 insertions(+), 5 deletions(-) create mode 100644 docs/adr-009-prompt-source-field.md create mode 100644 internal/claudecodec/prompt_source_test.go diff --git a/SKILL.md b/SKILL.md index 5b34b80..5ffb651 100644 --- a/SKILL.md +++ b/SKILL.md @@ -50,8 +50,9 @@ inherit 記住讀取進度,重複呼叫同一個命令即自動翻頁: ## 輸出行為 - 每則訊息的 header 只帶時鐘 `[HH:MM:SS]`,日期在換日時以獨立一行 `--- YYYY-MM-DD ---` 標示 -- 角色標籤三種:`user:` 是人打的、`assistant:` 是 Claude 的回應、`harness:` 是 Claude Code 自己塞進對話的訊息 - (stop hook 目標、背景任務完成通知、壓縮續接摘要、中斷標記等),不是使用者說的話 +- 角色標籤四種:`user:` 是人打的、`assistant:` 是 Claude 的回應、`harness:` 是 Claude Code 自己塞進對話的訊息 + (stop hook 目標、背景任務完成通知、壓縮續接摘要、中斷標記等),不是使用者說的話; + `user (sdk):` 是透過 SDK 或 `claude -p` 程式化送入的訊息,不是人手打的,但在對話裡仍站在使用者的位置 - tool 摘要行只在失敗時標 `FAILED`,成功不另標記;Bash 行帶 `| <程式名 子命令>` 讓讀者知道實際跑了什麼 - 當 session 內有 `cc-session inherit/read/context` 呼叫時,連續的同 session 呼叫會被壓成一行: `(cc-session#Y1dg: inherited session 16d06326 here, 1320 lines omitted)` diff --git a/docs/adr-009-prompt-source-field.md b/docs/adr-009-prompt-source-field.md new file mode 100644 index 0000000..979befb --- /dev/null +++ b/docs/adr-009-prompt-source-field.md @@ -0,0 +1,88 @@ +# ADR-009:訊息來源先看 `promptSource` 欄位,字串比對降為備援 + +**狀態**:已實作(2026-09-02)。`sdk` 來源標 `user (sdk):`;context 格式的短前綴是 `S:`(`U:`/`H:` 之外新增一個)。 + +判斷 promptSource 是否與字串分類衝突(決定 4)、以及決定 3 的「system 但形狀認不出來」, +兩者的解析都收在 parser 層(`claudecodec.parseLineWithToolCalls`):字串分類先跑一次, +結果不論如何都先掛上 `PromptSource`;只有人的來源對上 harness 形狀時才整則重置成純文字。 +render 層因此不需要重新判斷字串分類,只在「字串分類完全沒認出形狀」的兜底分支上, +依 `PromptSource` 決定 `harness:`/`user (sdk):`/`user:`(`session.UserMessage.IsClassifiedAsHarness` +與 `formatter.plainTextRole`)。實測 `b11858cf` 的 8 則 `system` 訊息全是 `` +這種已認得的形狀,`read` 輸出的 md5 改動前後一致。 + +ADR-008 把 harness 注入的 user 訊息在 parser 層分類,但判斷全靠比對文字。 +每次 harness 改措辭或加新的包裝,reader 就漏一批(ADR-008 之後兩天又補了八種)。 +CLI 從 2.1.165 起在 user entry 上寫 `promptSource` 欄位,這份 ADR 記錄它能替代多少字串比對。 + +量測基礎:`~/.claude/projects` 下最近 60 天的主 session 與 `subagents/` 兩層 transcript, +版本 2.1.165 以上的 user 文字 entry 共 11,064 則(2026-09-02)。 + +## 欄位長什麼樣 + +| 值 | 則數 | 意思 | 啟動一輪的比例 | +|---|---:|---|---:| +| `typed` | 3,547 | 人在終端打的 | 89.6% | +| `sdk` | 1,213 | SDK 或 `claude -p` 程式化送入 | 98.3% | +| `system` | 1,206 | harness 注入 | 93.9% | +| `queued` | 158 | 人排隊送出的 | 93.0% | +| `suggestion_accepted` | 50 | 人接受了建議 | 96.0% | +| 沒帶 | 4,890 | 見下節 | | + +「啟動一輪」的判準同 ADR-008 第 5 項:往後掃到下一則 user 文字為止,中間有帶 usage 的 assistant 訊息。 +五種值全都在九成上下,也就是**帶 `promptSource` 的訊息就是啟動 turn 的 prompt**。 + +## 沒帶的那 44% 不是缺資料,是另一類訊息 + +沒帶 `promptSource` 的 4,890 則裡,前 22 種形狀全是 harness 注入或附屬訊息: + +| 形狀 | 則數 | +|---|---:| +| teammate message(開場白起頭 1,080 + 標籤直接起頭 769) | 1,849 | +| skill 注入 `Base directory for this skill:` | 464 | +| `` | 439 | +| `[Request interrupted by user]` | 244 | +| `[Image: …]` 佔位符 | 237 | +| `` / `` / `` | 394 | +| 壓縮續接、`[SYSTEM NOTIFICATION]`、coordinator、fork-boilerplate、nudge、mid-turn 使用者訊息 | 271 | + +所以這個欄位的語意是:**有帶 = 這則是啟動 turn 的 prompt;沒帶 = 這則是注入或附屬**。 +「沒帶」本身就是訊號,不是缺值。 + +覆蓋率依版本在 25% 到 89% 之間浮動,但那反映的是各版本注入訊息的比例不同,不是欄位時有時無。 + +## 與字串比對的衝突 + +| 情況 | 則數 | +|---|---:| +| 人的來源(`typed`/`queued`/`suggestion_accepted`)但字串比對判成 harness | **0** | +| `system` 但字串比對判不出來 | 15 | + +那 15 則是排程或迴圈觸發的 prompt(`Check if background task …`、`Background agent "…"`、 +`檢查 /qa skill fork…`),內容是自然語言,沒有任何可比對的形狀。 +只有 `promptSource` 抓得到它們。 + +## 決定 + +1. **有 `promptSource` 時以它為準。** `system` 歸 harness 角色;`typed`、`queued`、`suggestion_accepted` 歸 user; + `sdk` 歸 user(它是呼叫方下的指令,在那份 transcript 裡就是使用者的位置),但在 header 標 `user (sdk):`, + 讓讀者知道不是人手打的。 +2. **`CountsAsTurn()` 對帶 `promptSource` 的訊息一律回 true**,不分值。五種值實測都啟動一輪。 + ADR-008 第 5 項那張逐種類的表只剩「沒帶」的訊息需要。 +3. **字串比對留著,只處理沒帶的訊息。** 沒帶的訊息才需要知道是哪一種注入(決定 compact 形式)。 + 帶 `promptSource=system` 但字串比對判不出形狀的(上面那 15 則),渲染全文、標 `harness:`。 +4. **`promptSource` 與字串比對結論相反時以 `promptSource` 為準。** 實測 0 例,這條是預先定好,避免以後各寫各的。 +5. **2.1.165 以前的 transcript 走現行路徑**,不變。 + +## 不做的 + +**不把「沒帶 `promptSource`」直接當成 harness。** `[Image: …]` 佔位符沒帶但屬於使用者訊息, +`The user sent a new message while you were working:` 沒帶但 body 是人打的。 +沒帶只代表「不是啟動 turn 的 prompt」,是不是人講的還要看內容。 + +**不用 `isMeta` 當第二來源。** 它在 skill 注入、圖片佔位符、stop hook feedback 上都出現,語意是「附屬」而不是「harness」。 + +## 預期效果 + +- `harness:` 標籤多抓到 15 則現在判不出來的排程 prompt +- 之後 harness 改措辭,影響的只剩 compact 形式(會退化成全文渲染),不再影響角色標籤與 K +- K 不變:帶 `promptSource` 的訊息在現行政策下本來就幾乎都算 turn(`system` 那組 91% 是 task-notification,ADR-008 已算) diff --git a/internal/claudecodec/model.go b/internal/claudecodec/model.go index ff409ef..654ce04 100644 --- a/internal/claudecodec/model.go +++ b/internal/claudecodec/model.go @@ -30,6 +30,12 @@ type rawEntry struct { // preceding tool_use's name, not treated as a marker on its own. IsMeta bool `json:"isMeta"` SourceToolUseID string `json:"sourceToolUseID"` + + // PromptSource is written by Claude Code (CLI >= 2.1.165) on some user + // entries: "typed", "sdk", "system", "queued", "suggestion_accepted". + // Absent on older transcripts and on harness injections/mid-turn relays + // even under current CLI (ADR-009). + PromptSource string `json:"promptSource"` } type rawMessage struct { diff --git a/internal/claudecodec/prompt_source_test.go b/internal/claudecodec/prompt_source_test.go new file mode 100644 index 0000000..5025762 --- /dev/null +++ b/internal/claudecodec/prompt_source_test.go @@ -0,0 +1,116 @@ +package claudecodec + +import ( + "testing" + + "github.com/Mapleeeeeeeeeee/cc-session-reader/internal/session" +) + +// ADR-009: Claude Code (CLI >= 2.1.165) writes a top-level "promptSource" +// field on some user entries. These cases pin how it is carried onto +// session.UserMessage and how it interacts with the pre-existing text +// classifiers in classify.go. + +func userMessageEventFor(t *testing.T, line string) *session.UserMessage { + t.Helper() + event, ok, err := ParseLine([]byte(line)) + if err != nil { + t.Fatalf("ParseLine returned error: %v", err) + } + if !ok { + t.Fatalf("ParseLine dropped the entry") + } + if event.User == nil { + t.Fatalf("event.User is nil") + } + return event.User +} + +func TestParseLine_GivenPromptSource_WhenParsed_ThenCarriesItOntoUserMessage(t *testing.T) { + line := `{"type":"user","timestamp":"2026-09-02T00:00:00Z","message":{"role":"user","content":"為什麼 K 會被高估?"},"promptSource":"typed"}` + + got := userMessageEventFor(t, line) + + if got.PromptSource != session.PromptSourceTyped { + t.Errorf("PromptSource = %q, want %q", got.PromptSource, session.PromptSourceTyped) + } + if got.Text != "為什麼 K 會被高估?" { + t.Errorf("Text = %q, want the original body", got.Text) + } +} + +func TestParseLine_GivenNoPromptSourceField_WhenParsed_ThenLeavesItEmpty(t *testing.T) { + line := `{"type":"user","timestamp":"2026-09-02T00:00:00Z","message":{"role":"user","content":"為什麼 K 會被高估?"}}` + + got := userMessageEventFor(t, line) + + if got.PromptSource != "" { + t.Errorf("PromptSource = %q, want empty (field absent)", got.PromptSource) + } +} + +// ADR-009 decision 3: a promptSource="system" message that the string +// classifier does recognize keeps its existing compact form (the classified +// domain field survives; only PromptSource is added on top of it). +func TestParseLine_GivenSystemPromptSourceOnRecognizedShape_WhenParsed_ThenKeepsTheClassifiedForm(t *testing.T) { + line := `{"type":"user","timestamp":"2026-09-02T00:00:00Z",` + + `"message":{"role":"user","content":"7 background agents were stopped by the user: \"工作區\"."},` + + `"promptSource":"system"}` + + got := userMessageEventFor(t, line) + + if !got.IsAgentsStopped { + t.Fatalf("IsAgentsStopped = false, want true (classified form preserved)") + } + if got.StoppedAgentCount != 7 { + t.Errorf("StoppedAgentCount = %d, want 7", got.StoppedAgentCount) + } + if got.PromptSource != session.PromptSourceSystem { + t.Errorf("PromptSource = %q, want %q", got.PromptSource, session.PromptSourceSystem) + } +} + +// ADR-009 decision 3: the 15 observed messages a scheduled/looped prompt +// with promptSource="system" but a shape none of the text classifiers +// recognize — only promptSource itself flags these as harness, at the +// render layer (see harness_role_test.go), not by reclassifying the body +// here. +func TestParseLine_GivenSystemPromptSourceOnUnrecognizedShape_WhenParsed_ThenLeavesTextUnclassified(t *testing.T) { + line := `{"type":"user","timestamp":"2026-09-02T00:00:00Z",` + + `"message":{"role":"user","content":"Check if background task abc123 finished."},` + + `"promptSource":"system"}` + + got := userMessageEventFor(t, line) + + if got.IsClassifiedAsHarness() { + t.Errorf("IsClassifiedAsHarness() = true, want false: no text classifier recognizes this shape") + } + if got.Text != "Check if background task abc123 finished." { + t.Errorf("Text = %q, want the original body kept verbatim", got.Text) + } + if got.PromptSource != session.PromptSourceSystem { + t.Errorf("PromptSource = %q, want %q", got.PromptSource, session.PromptSourceSystem) + } +} + +// ADR-009 decision 4: 0 observed conflicts, but the rule is pinned so a +// future rewording of a harness message can't silently mislabel a message a +// human actually sent (e.g. a person pasting harness-looking text while +// programmatically driving the CLI via the SDK). +func TestParseLine_GivenHumanPromptSourceOnHarnessShapedBody_WhenParsed_ThenPromptSourceWins(t *testing.T) { + line := `{"type":"user","timestamp":"2026-09-02T00:00:00Z",` + + `"message":{"role":"user","content":"[Request interrupted by user]"},` + + `"promptSource":"sdk"}` + + got := userMessageEventFor(t, line) + + if got.IsInterrupted { + t.Errorf("IsInterrupted = true, want false: a human promptSource overrules the harness-shaped body") + } + if got.Text != "[Request interrupted by user]" { + t.Errorf("Text = %q, want the original body kept verbatim", got.Text) + } + if got.PromptSource != session.PromptSourceSDK { + t.Errorf("PromptSource = %q, want %q", got.PromptSource, session.PromptSourceSDK) + } +} diff --git a/internal/claudecodec/reader.go b/internal/claudecodec/reader.go index ccbfa73..86da848 100644 --- a/internal/claudecodec/reader.go +++ b/internal/claudecodec/reader.go @@ -188,6 +188,14 @@ func parseLineWithToolCalls(line []byte, toolCalls map[string]toolCallInfo) (ses } else { event.User = &session.UserMessage{Text: text} } + event.User.PromptSource = raw.PromptSource + // ADR-009 decision 4: a human promptSource overrules a harness verdict + // from the text classifiers above (0 observed conflicts, but the rule + // is pinned so a future rewording can't quietly flip the label). The + // message reverts to a plain user message under its original text. + if session.IsHumanPromptSource(raw.PromptSource) && event.User.IsClassifiedAsHarness() { + event.User = &session.UserMessage{Text: text, PromptSource: raw.PromptSource} + } return event, true, nil case "assistant": assistant := raw.Message.Assistant() diff --git a/internal/formatter/context.go b/internal/formatter/context.go index a2f9e04..6657472 100644 --- a/internal/formatter/context.go +++ b/internal/formatter/context.go @@ -54,8 +54,14 @@ func renderContextEvents(events []session.Event, agentIDs map[string]bool, opts } flush() prefix := "U" - if rendered.role == RoleHarness { + switch rendered.role { + case RoleHarness: prefix = "H" + case RoleUserSDK: + // ADR-009: "S" sits next to "U"/"H" for a promptSource="sdk" + // message, which is still the user's position but not typed + // by a person. + prefix = "S" } fmt.Fprintf(out, "%s: %s\n\n", prefix, rendered.body) diff --git a/internal/formatter/harness_role_test.go b/internal/formatter/harness_role_test.go index 60f9086..b3880d5 100644 --- a/internal/formatter/harness_role_test.go +++ b/internal/formatter/harness_role_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/Mapleeeeeeeeeee/cc-session-reader/internal/claudecodec" "github.com/Mapleeeeeeeeeee/cc-session-reader/internal/session" ) @@ -131,6 +132,103 @@ func TestFormatReadEvents_GivenMidTurnUserMessage_WhenRendered_ThenLabelsItUserW } } +// ADR-009: promptSource="sdk" means the message is still the user's +// position in the transcript, but wasn't typed by a person, so it gets its +// own label rather than folding into either "user:" or "harness:". +func TestFormatReadEvents_GivenSDKPromptSource_WhenRendered_ThenLabelsItUserSDK(t *testing.T) { + events := []session.Event{{ + Kind: session.EventUserMessage, + Timestamp: "2026-09-02T00:00:00Z", + User: &session.UserMessage{Text: "run the full test suite", PromptSource: session.PromptSourceSDK}, + }} + + var out bytes.Buffer + if err := FormatReadEvents(events, nil, 0, 0, FormatOptions{}, &out); err != nil { + t.Fatalf("FormatReadEvents returned error: %v", err) + } + + want := "[00:00:00] user (sdk):\nrun the full test suite" + if got := out.String(); !strings.Contains(got, want) { + t.Errorf("output missing %q\ngot:\n%s", want, got) + } +} + +// ADR-009 decision 3: the 15 observed promptSource="system" messages whose +// body no text classifier recognizes still render in full (not compacted or +// dropped), but under the harness role rather than plain "user:". +func TestFormatReadEvents_GivenSystemPromptSourceOnUnrecognizedBody_WhenRendered_ThenLabelsItHarnessInFull(t *testing.T) { + events := []session.Event{{ + Kind: session.EventUserMessage, + Timestamp: "2026-09-02T00:00:00Z", + User: &session.UserMessage{ + Text: "Check if background task abc123 finished.", + PromptSource: session.PromptSourceSystem, + }, + }} + + var out bytes.Buffer + if err := FormatReadEvents(events, nil, 0, 0, FormatOptions{}, &out); err != nil { + t.Fatalf("FormatReadEvents returned error: %v", err) + } + + want := "[00:00:00] harness:\nCheck if background task abc123 finished." + if got := out.String(); !strings.Contains(got, want) { + t.Errorf("output missing %q\ngot:\n%s", want, got) + } +} + +// ADR-009 decision 4: 0 observed conflicts, but pinned so a future harness +// rewording can't silently mislabel a message a human actually sent — a +// human promptSource overrules a harness-shaped body and renders under the +// user role with the body kept verbatim (no compact form applied). The +// override happens in the parser (claudecodec.ParseLine), so this exercises +// the full pipeline rather than hand-assembling a UserMessage state the +// parser would never actually produce. +func TestFormatReadEvents_GivenHumanPromptSourceOnHarnessShapedBody_WhenRendered_ThenLabelsItUser(t *testing.T) { + line := `{"type":"user","timestamp":"2026-09-02T00:00:00Z",` + + `"message":{"role":"user","content":"[Request interrupted by user]"},` + + `"promptSource":"typed"}` + event, ok, err := claudecodec.ParseLine([]byte(line)) + if err != nil || !ok { + t.Fatalf("ParseLine(%q) = %v, %v, %v", line, event, ok, err) + } + + var out bytes.Buffer + if err := FormatReadEvents([]session.Event{event}, nil, 0, 0, FormatOptions{}, &out); err != nil { + t.Fatalf("FormatReadEvents returned error: %v", err) + } + + got := out.String() + if want := "[00:00:00] user:\n[Request interrupted by user]"; !strings.Contains(got, want) { + t.Errorf("output missing %q\ngot:\n%s", want, got) + } + if strings.Contains(got, "[interrupted]") { + t.Errorf("output still shows the compact harness marker\ngot:\n%s", got) + } +} + +// ADR-009 decision 5: a message with no promptSource field (older CLI, or a +// harness injection/mid-turn relay under current CLI) keeps today's +// behavior exactly — this pins that the new field is additive, not a +// default that changes existing output. +func TestFormatReadEvents_GivenNoPromptSource_WhenRendered_ThenBehavesAsBeforeADR009(t *testing.T) { + events := []session.Event{{ + Kind: session.EventUserMessage, + Timestamp: "2026-09-02T00:00:00Z", + User: &session.UserMessage{Text: "為什麼 K 會被高估?"}, + }} + + var out bytes.Buffer + if err := FormatReadEvents(events, nil, 0, 0, FormatOptions{}, &out); err != nil { + t.Fatalf("FormatReadEvents returned error: %v", err) + } + + want := "[00:00:00] user:\n為什麼 K 會被高估?" + if got := out.String(); !strings.Contains(got, want) { + t.Errorf("output missing %q\ngot:\n%s", want, 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 539198f..a3c6ec9 100644 --- a/internal/formatter/render.go +++ b/internal/formatter/render.go @@ -52,10 +52,13 @@ const ( // Role labels written in the per-message header. RoleHarness separates // messages the harness injected from messages the user typed: before ADR-008 // both were labelled "user", so a reader inheriting the transcript could not -// tell which lines a person actually wrote. +// tell which lines a person actually wrote. RoleUserSDK further separates a +// promptSource="sdk" message (ADR-009): it is still the user's position in +// the transcript, but not something a person typed. const ( RoleUser = "user" RoleHarness = "harness" + RoleUserSDK = "user (sdk)" ) // userRender is the rendered form of a user-message event: the body to print, @@ -150,13 +153,30 @@ func renderUserMessage(user *session.UserMessage, opts FormatOptions, seenSkills if strings.TrimSpace(user.Text) == "" { return userRender{} } - return userRender{body: user.Text, role: RoleUser, show: true} + return userRender{body: user.Text, role: plainTextRole(user.PromptSource), show: true} } func harnessRender(body string) userRender { return userRender{body: body, role: RoleHarness, show: true} } +// plainTextRole resolves the role label for a message none of the text +// classifiers recognized. ADR-009 decision 1/3: promptSource="system" means +// harness injected it even though no known shape matched (renders in full, +// unlike a recognized shape's compact form); "sdk" gets its own label so a +// reader can tell it wasn't typed by a person. Every other value, including +// absent, keeps the plain user role. +func plainTextRole(promptSource string) string { + switch promptSource { + case session.PromptSourceSystem: + return RoleHarness + case session.PromptSourceSDK: + return RoleUserSDK + default: + return RoleUser + } +} + type pendingTool struct { toolUseID string summary string diff --git a/internal/session/event.go b/internal/session/event.go index 13f9850..142a229 100644 --- a/internal/session/event.go +++ b/internal/session/event.go @@ -144,6 +144,43 @@ type UserMessage struct { // the user role. MidTurnUserText is that body with the wrapper stripped. IsMidTurnUserMessage bool MidTurnUserText string + + // PromptSource carries the top-level "promptSource" field Claude Code + // (CLI >= 2.1.165) writes on some user entries (see the PromptSource* + // constants). Empty when the field is absent: older CLI versions never + // wrote it, and even on current CLI it marks only entries that start a + // turn — injections and mid-turn relays never carry it (ADR-009). + PromptSource string +} + +// Values of UserMessage.PromptSource, per ADR-009. +const ( + PromptSourceTyped = "typed" + PromptSourceSDK = "sdk" + PromptSourceSystem = "system" + PromptSourceQueued = "queued" + PromptSourceSuggestionAccepted = "suggestion_accepted" +) + +// IsHumanPromptSource reports whether source names a promptSource value that +// a person, not the harness, is the origin of: everything except "system" +// and "" (absent). ADR-009 decision 4 uses this to resolve a promptSource +// that disagrees with classifyHarnessUserMessage's text-based verdict. +func IsHumanPromptSource(source string) bool { + switch source { + case PromptSourceTyped, PromptSourceSDK, PromptSourceQueued, PromptSourceSuggestionAccepted: + return true + default: + return false + } +} + +// IsClassifiedAsHarness reports whether classifyHarnessUserMessage (or its +// isMeta-linked siblings) recognized this message as harness-injected, +// independent of PromptSource. ADR-009 decision 4: when a human +// PromptSource disagrees with this verdict, PromptSource wins. +func (u UserMessage) IsClassifiedAsHarness() bool { + return u.IsCompactedHarnessInjection() || u.IsSystemReminder || u.IsContextUsage } // CountsAsTurn reports whether this message starts a unit of agent work: an @@ -199,6 +236,13 @@ func (u UserMessage) IsCompactedHarnessInjection() bool { } func (u UserMessage) CountsAsTurn() bool { + // ADR-009: a message that carries promptSource always started a turn — + // measured 89-98% across all five values, including "system" (the + // task-notification/stop-hook/etc. table above only still matters for + // the 44% of messages with no promptSource at all). + if u.PromptSource != "" { + return true + } if u.CommandMarker != "" { return false } diff --git a/internal/session/turn_test.go b/internal/session/turn_test.go index 316dee5..87e822e 100644 --- a/internal/session/turn_test.go +++ b/internal/session/turn_test.go @@ -88,6 +88,26 @@ func TestCountsAsTurn_GivenMessageKind_WhenCounted_ThenFollowsWorkUnitPolicy(t * message: UserMessage{Text: "…", CommandMarker: "[/goal]", IsTeammateMessage: true}, want: false, }, + + // ADR-009: promptSource always wins, regardless of value — measured + // 89-98% across all five values, including on a command marker, which + // the pre-ADR-009 policy above says is not a turn. + "a promptSource is a turn regardless of value: typed": { + message: UserMessage{Text: "…", PromptSource: PromptSourceTyped}, + want: true, + }, + "a promptSource is a turn regardless of value: system": { + message: UserMessage{Text: "…", PromptSource: PromptSourceSystem}, + want: true, + }, + "a promptSource overrides even a command marker, which alone would not count": { + message: UserMessage{CommandMarker: "[/goal]", PromptSource: PromptSourceTyped}, + want: true, + }, + "a promptSource overrides even a kind the policy above says never starts a turn": { + message: UserMessage{Text: "…", IsInterrupted: true, PromptSource: PromptSourceSystem}, + want: true, + }, } for name, tc := range tests {