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
21 changes: 17 additions & 4 deletions docs/adr-009-prompt-source-field.md
Original file line number Diff line number Diff line change
@@ -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` 訊息全是 `<task-notification>`
Expand Down Expand Up @@ -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 注入(`<task-notification>`、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 走現行路徑**,不變。

## 不做的
Expand Down
28 changes: 25 additions & 3 deletions internal/claudecodec/prompt_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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":"<task-notification>\n<summary>benchmark done</summary>\n</task-notification>"},` +
`"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)
}
Expand Down
29 changes: 29 additions & 0 deletions internal/formatter/harness_role_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<task-notification>\n<summary>benchmark done</summary>\n</task-notification>"},` +
`"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, "<task-notification>") {
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
Expand Down
22 changes: 16 additions & 6 deletions internal/session/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 != "" {
Expand Down
31 changes: 31 additions & 0 deletions internal/session/turn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading