diff --git a/docs/adr-009-prompt-source-field.md b/docs/adr-009-prompt-source-field.md index 979befb..5c09949 100644 --- a/docs/adr-009-prompt-source-field.md +++ b/docs/adr-009-prompt-source-field.md @@ -1,10 +1,12 @@ # ADR-009:訊息來源先看 `promptSource` 欄位,字串比對降為備援 -**狀態**:已實作(2026-09-02)。`sdk` 來源標 `user (sdk):`;context 格式的短前綴是 `S:`(`U:`/`H:` 之外新增一個)。 +**狀態**:已實作(2026-09-02),決定 4 於同日修正排除 `sdk`(見該節)。 +字串分類認不出形狀的 `sdk` 來源標 `user (sdk):`;context 格式的短前綴是 `S:`(`U:`/`H:` 之外新增一個)。 判斷 promptSource 是否與字串分類衝突(決定 4)、以及決定 3 的「system 但形狀認不出來」, 兩者的解析都收在 parser 層(`claudecodec.parseLineWithToolCalls`):字串分類先跑一次, -結果不論如何都先掛上 `PromptSource`;只有人的來源對上 harness 形狀時才整則重置成純文字。 +結果不論如何都先掛上 `PromptSource`;只有 `typed`、`queued`、`suggestion_accepted` 這三個人的來源 +對上 harness 形狀時才整則重置成純文字,`sdk` 不在此列。 render 層因此不需要重新判斷字串分類,只在「字串分類完全沒認出形狀」的兜底分支上, 依 `PromptSource` 決定 `harness:`/`user (sdk):`/`user:`(`session.UserMessage.IsClassifiedAsHarness` 與 `formatter.plainTextRole`)。實測 `b11858cf` 的 8 則 `system` 訊息全是 `` @@ -65,12 +67,23 @@ CLI 從 2.1.165 起在 user entry 上寫 `promptSource` 欄位,這份 ADR 記 1. **有 `promptSource` 時以它為準。** `system` 歸 harness 角色;`typed`、`queued`、`suggestion_accepted` 歸 user; `sdk` 歸 user(它是呼叫方下的指令,在那份 transcript 裡就是使用者的位置),但在 header 標 `user (sdk):`, - 讓讀者知道不是人手打的。 + 讓讀者知道不是人手打的。**`sdk` 標的是這份 session 由誰驅動,不是這一則訊息由誰寫的**: + harness 注入(``、stop hook 等)在 SDK 或 `claude -p` 驅動的 session 裡 + 一樣會帶 `sdk`,跟人手打的訊息沒有欄位上的差異,所以決定 4 的字串分類覆蓋規則排除 `sdk`。 2. **`CountsAsTurn()` 對帶 `promptSource` 的訊息一律回 true**,不分值。五種值實測都啟動一輪。 ADR-008 第 5 項那張逐種類的表只剩「沒帶」的訊息需要。 3. **字串比對留著,只處理沒帶的訊息。** 沒帶的訊息才需要知道是哪一種注入(決定 compact 形式)。 帶 `promptSource=system` 但字串比對判不出形狀的(上面那 15 則),渲染全文、標 `harness:`。 -4. **`promptSource` 與字串比對結論相反時以 `promptSource` 為準。** 實測 0 例,這條是預先定好,避免以後各寫各的。 +4. **`promptSource` 與字串比對結論相反時以 `promptSource` 為準,但 `sdk` 除外。** `typed`、`queued`、 + `suggestion_accepted` 這三個值只在人打字或按下建議時才會出現,跟字串分類衝突就代表分類錯了; + `sdk` 不是,它在整份 session 裡固定不變,harness 注入繼承的是驅動方式而不是訊息作者。 + 字串分類認出 harness 形狀時,`sdk` 保留那個分類,渲染成 compact 形式、標 `harness:`; + 分類認不出的 `sdk` 訊息才標 `user (sdk):`。`CountsAsTurn()` 比照同一條界線: + `sdk` 上帶 ADR-008 判定不算一輪的形狀(`interrupted`、`agents-stopped`、stop hook、skill 注入等)時, + 跟著形狀的判定走,不再套用「帶 `promptSource` 就一定算一輪」的通則。 + + 這條規則原本寫的是 `sdk` 也適用覆蓋(實測 0 衝突),v0.1.76(PR #13)依此實作後, + sdk 來源中有 harness 形狀的訊息以 v0.1.76 實測會被展開成全文,標成 `user (sdk):`。 5. **2.1.165 以前的 transcript 走現行路徑**,不變。 ## 不做的 diff --git a/internal/claudecodec/prompt_source_test.go b/internal/claudecodec/prompt_source_test.go index 5025762..7c5328e 100644 --- a/internal/claudecodec/prompt_source_test.go +++ b/internal/claudecodec/prompt_source_test.go @@ -95,12 +95,11 @@ func TestParseLine_GivenSystemPromptSourceOnUnrecognizedShape_WhenParsed_ThenLea // 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). +// human actually sent. 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"}` + `"promptSource":"typed"}` got := userMessageEventFor(t, line) @@ -110,6 +109,29 @@ func TestParseLine_GivenHumanPromptSourceOnHarnessShapedBody_WhenParsed_ThenProm if got.Text != "[Request interrupted by user]" { t.Errorf("Text = %q, want the original body kept verbatim", got.Text) } + if got.PromptSource != session.PromptSourceTyped { + t.Errorf("PromptSource = %q, want %q", got.PromptSource, session.PromptSourceTyped) + } +} + +// Regression: v0.1.76 (ADR-009, PR #13) treated "sdk" as a human source, so +// this task-notification's harness shape got overridden and it rendered as +// raw XML under "user (sdk):" instead of the compact form under "harness:". +// Measured on session 5646d1ce: output grew from 217,272 to 234,940 bytes and +// every harness injection in an sdk-driven session lost its compact form. +// "sdk" marks the session's driver, not who authored a given entry — a +// harness injection inherits it from the surrounding session the same way a +// human-typed prompt does, so a recognized harness shape must win over it. +func TestParseLine_GivenSDKPromptSourceOnRecognizedHarnessShape_WhenParsed_ThenKeepsTheClassifiedForm(t *testing.T) { + line := `{"type":"user","timestamp":"2026-09-02T00:00:00Z",` + + `"message":{"role":"user","content":"\nbenchmark done\n"},` + + `"promptSource":"sdk"}` + + got := userMessageEventFor(t, line) + + if !got.IsTaskNotification { + t.Fatalf("IsTaskNotification = false, want true: an sdk promptSource must not override a recognized harness shape") + } if got.PromptSource != session.PromptSourceSDK { t.Errorf("PromptSource = %q, want %q", got.PromptSource, session.PromptSourceSDK) } diff --git a/internal/formatter/harness_role_test.go b/internal/formatter/harness_role_test.go index b3880d5..dafa8a7 100644 --- a/internal/formatter/harness_role_test.go +++ b/internal/formatter/harness_role_test.go @@ -207,6 +207,35 @@ func TestFormatReadEvents_GivenHumanPromptSourceOnHarnessShapedBody_WhenRendered } } +// Regression: v0.1.76 (ADR-009, PR #13) treated "sdk" as a human source, so +// a task-notification carrying it lost its compact form and rendered as raw +// XML under "user (sdk):" instead of "harness:". "sdk" marks the session's +// driver, not who authored a given entry — a harness injection inherits it +// from the surrounding sdk-driven session, so a recognized harness shape +// must still win and render compact under the harness role. +func TestFormatReadEvents_GivenSDKPromptSourceOnRecognizedHarnessShape_WhenRendered_ThenLabelsItHarnessCompact(t *testing.T) { + line := `{"type":"user","timestamp":"2026-09-02T00:00:00Z",` + + `"message":{"role":"user","content":"\nbenchmark done\n"},` + + `"promptSource":"sdk"}` + 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] harness:\n[benchmark done]"; !strings.Contains(got, want) { + t.Errorf("output missing %q\ngot:\n%s", want, got) + } + if strings.Contains(got, "user (sdk)") || strings.Contains(got, "") { + t.Errorf("output still shows the raw sdk-labeled form\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 diff --git a/internal/session/event.go b/internal/session/event.go index 142a229..7ce6e37 100644 --- a/internal/session/event.go +++ b/internal/session/event.go @@ -163,12 +163,18 @@ const ( ) // 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. +// always marks a human-authored entry, so it overrules a harness verdict +// from classifyHarnessUserMessage's text-based classifiers (ADR-009 decision +// 4): "typed", "queued", "suggestion_accepted". It excludes "system" (the +// harness) and "sdk": "sdk" marks which driver ran the session, not who +// authored a given entry, and harness injections (task-notifications, stop +// hooks, ...) inherit it from the surrounding sdk-driven session just like a +// human-typed prompt does. A recognized harness shape therefore wins over an +// "sdk" promptSource instead of being overridden by it (ADR-009 amendment, +// regression on session 5646d1ce). func IsHumanPromptSource(source string) bool { switch source { - case PromptSourceTyped, PromptSourceSDK, PromptSourceQueued, PromptSourceSuggestionAccepted: + case PromptSourceTyped, PromptSourceQueued, PromptSourceSuggestionAccepted: return true default: return false @@ -239,8 +245,12 @@ 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 != "" { + // the 44% of messages with no promptSource at all). Exception: "sdk" on + // a body a text classifier recognized as a harness shape is inherited + // from the sdk-driven session, not evidence this particular entry + // started a turn (same reasoning as IsHumanPromptSource), so those defer + // to the per-shape verdict below instead of the blanket true. + if u.PromptSource != "" && !(u.PromptSource == PromptSourceSDK && u.IsClassifiedAsHarness()) { return true } if u.CommandMarker != "" { diff --git a/internal/session/turn_test.go b/internal/session/turn_test.go index 87e822e..6904bb4 100644 --- a/internal/session/turn_test.go +++ b/internal/session/turn_test.go @@ -108,6 +108,37 @@ func TestCountsAsTurn_GivenMessageKind_WhenCounted_ThenFollowsWorkUnitPolicy(t * message: UserMessage{Text: "…", IsInterrupted: true, PromptSource: PromptSourceSystem}, want: true, }, + + // Regression: v0.1.76 (ADR-009, PR #13) let PromptSource=sdk override + // CountsAsTurn unconditionally, same as every other value. "sdk" marks + // the session's driver, not who authored a given entry -- a harness + // injection inherits it from the surrounding sdk-driven session, so it + // must defer to its own shape's verdict (ADR-008) instead of always + // counting. + "a promptSource of sdk with no recognized shape still counts (it is a genuine sdk-driven prompt)": { + message: UserMessage{Text: "…", PromptSource: PromptSourceSDK}, + want: true, + }, + "a promptSource of sdk on a task notification counts, per the shape's own verdict": { + message: UserMessage{Text: "…", IsTaskNotification: true, PromptSource: PromptSourceSDK}, + want: true, + }, + "a promptSource of sdk on an interruption sentinel does not count, per the shape's own verdict": { + message: UserMessage{Text: "…", IsInterrupted: true, PromptSource: PromptSourceSDK}, + want: false, + }, + "a promptSource of sdk on an agents-stopped notice does not count, per the shape's own verdict": { + message: UserMessage{Text: "…", IsAgentsStopped: true, PromptSource: PromptSourceSDK}, + want: false, + }, + "a promptSource of sdk on a stop hook notice does not count, per the shape's own verdict": { + message: UserMessage{Text: "…", IsStopHookGoal: true, PromptSource: PromptSourceSDK}, + want: false, + }, + "a promptSource of sdk on a skill injection does not count, per the shape's own verdict": { + message: UserMessage{Text: "…", IsSkillInjection: true, PromptSource: PromptSourceSDK}, + want: false, + }, } for name, tc := range tests {