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
3 changes: 3 additions & 0 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -73607,6 +73607,9 @@
],
"type": "string"
},
"model": {
"type": "string"
},
"model_attempt": {
"format": "int32",
"type": "integer"
Expand Down
43 changes: 43 additions & 0 deletions docs/reviews/reply-model-attribution/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 每条回复的模型归属:OCR delegate 复审

日期:2026-10-28。基线:`8d9ce6ac918c0e142ac1089afa1022d85b750281`(origin/main)。
主题:#470 使模型可中途切换后,transcript 无法回答"这条回复来自哪个模型"。
本变更把模型调用事件已有的身份(`modelIdentity()`:`provider / model`)作为
只读 `omitempty` 字段穿透 runactivity → threadtranscript → HTTP view,
web 叙事投影按 attempt 关联,并在持久化回复的操作行显示灰字徽标(流式不显示)。

OCR `delegate preview` 选出 5 个可审生产文件;`delegate rule` 选定 Go 组
(错误/API 契约、nil 与值语义、性能分配)与 TS/React、CSS 组。独立评审子代理
全文核验 5 个生产文件与全量 diff。

## 覆盖范围

共 13 个文件;排除审阅元数据 2 个后纳入 11 个:生产 5(activity.go、
transcript.go、views.go、narrative.ts、narrative.tsx)+ 再生成 2(docs/openapi.json、
schema.d.ts,delegate 核验为纯再生成产物)+ 样式 1(styles.css)+ 测试 3。
明细见 [coverage.json](coverage.json)。

```text
ocr delegate preview --from 8d9ce6ac918c0e142ac1089afa1022d85b750281 --to HEAD
ocr delegate rule internal/runactivity/activity.go internal/threadtranscript/transcript.go internal/httpapi/views.go web/src/v2/projection/narrative.ts web/src/v2/components/narrative.tsx
```

## 发现与修复(评审子代理报告,全部已修复并复测)

| 发现 | 具体问题 | 最终处理 |
| --- | --- | --- |
| JOIN-001,P1 | model_call 投影只设置了 `Model` 而未提取 `attempt_id`,web 端 join 键永远缺失——真实数据下徽标不会渲染(首版 web 测试以手写键掩盖了断链)。 | ModelStarted/ModelCompleted 两个 case 增加 `base.AttemptID = cleanLabel(stringField(payload,"attempt_id"))`;新增 Go 贯穿测试以真实负载形状断言 call 项同时携带 attempt 与 model。 |
| REPLY-001,P2 | 最终持久回复经 SessionMessageEvent 投影,负载不含 attempt——仅 commentary 行可归属,主表面无效。 | threadtranscript 构建循环按 run 跟踪最近一次模型调用的 attempt,助手回复项继承之;零 store 负载改动。新增贯穿测试断言 reply 项继承 attempt-7。 |
| PERF-001,P3 | modelIdentity 在 started/completed case 各被重复调用(completed 路径叠加 completedModelDetail 内部一次),重复反序列化负载。 | 提升为单次 `identity := modelIdentity(...)` 同时赋 Detail/Model;completedModelDetail 改为接收 identity 参数(唯一调用方)。 |
| MERGE-001,P3 | 叙事合并分支(重复收尾回复并入)只更新文本与 ID,后来条目的 model 被静默丢弃。 | `previous.model ??= model`(首见优先,缺失时回填),注释写明语义。 |

评审子代理同时核验通过:字段仅沿既有只读投影链流动(无新写路径/锁/goroutine)、
`omitempty` 契约与同类字段一致、docs/openapi.json 与 schema.d.ts 为纯再生成产物、
attempt_id 全局唯一(idgen)无需 run 复合键、徽标无 XSS、durable-only 守卫成立、
CSS 仅用既有变量。

## 验证结论

- Go:`go test ./internal/runactivity/ ./internal/threadtranscript/ ./internal/httpapi/` 全过(含新增贯穿测试);`go build ./...`、`go vet` 通过。
- Web:`vitest` 投影+组件 38/38(含归属关联、无 attempt 不挂徽标、流式不显示);`npm test` 全量通过;`tsc -b` 零错误。
- 已知范围:attribution 依赖模型调用事件负载中的 attempt_id/model/provider——与 #469 的 usage 口径同源;无 attempt 的历史项不显示徽标(不猜测)。
37 changes: 37 additions & 0 deletions docs/reviews/reply-model-attribution/coverage.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"schema_version": "1",
"mode": "ocr_delegate",
"base": "8d9ce6ac918c0e142ac1089afa1022d85b750281",
"reviewed_head": "HEAD",
"review_metadata_excluded": [
"docs/reviews/reply-model-attribution/README.md",
"docs/reviews/reply-model-attribution/coverage.json"
],
"reviewers": [
"主 agent:模型归属全栈实现(runactivity/threadtranscript/httpapi/web 投影与徽标)及全部回归",
"review_delegate:OCR delegate 规则检查(Go 组:错误/API 契约、nil 与值语义、性能分配;TS/React 组;CSS 组)与独立全文复审"
],
"summary": {
"total_git_changed_files": 13,
"review_scope_files": 11,
"ocr_selected_production": 5,
"manually_added_tests_and_generated": 6,
"review_metadata_excluded_count": 2,
"unread_in_scope": 0,
"coverage_rate": 1.0
},
"files": [
{ "path": "internal/runactivity/activity.go", "coverage": "full", "reviewer": "primary_and_delegate" },
{ "path": "internal/threadtranscript/transcript.go", "coverage": "full", "reviewer": "primary_and_delegate" },
{ "path": "internal/httpapi/views.go", "coverage": "full", "reviewer": "primary_and_delegate" },
{ "path": "web/src/v2/projection/narrative.ts", "coverage": "full", "reviewer": "primary_and_delegate" },
{ "path": "web/src/v2/components/narrative.tsx", "coverage": "full", "reviewer": "primary_and_delegate" },
{ "path": "web/src/v2/styles.css", "coverage": "full", "reviewer": "primary_and_delegate" },
{ "path": "docs/openapi.json", "coverage": "regenerated", "reviewer": "delegate" },
{ "path": "web/src/api/schema.d.ts", "coverage": "regenerated", "reviewer": "delegate" },
{ "path": "internal/runactivity/activity_test.go", "coverage": "full", "reviewer": "primary" },
{ "path": "internal/threadtranscript/transcript_test.go", "coverage": "full", "reviewer": "primary" },
{ "path": "web/src/v2/projection/narrative.test.ts", "coverage": "full", "reviewer": "primary" },
{ "path": "web/src/v2/components/narrative.test.tsx", "coverage": "full", "reviewer": "primary" }
]
}
3 changes: 2 additions & 1 deletion internal/httpapi/views.go
Original file line number Diff line number Diff line change
Expand Up @@ -1225,6 +1225,7 @@ type ThreadTranscriptItemView struct {
InstructionAuthorized bool `json:"instruction_authorized"`
AttemptID string `json:"attempt_id,omitempty"`
ModelAttempt int `json:"model_attempt,omitempty"`
Model string `json:"model,omitempty"`
ToolRound int `json:"tool_round,omitempty"`
ToolName string `json:"tool_name,omitempty"`
StreamResponseID string `json:"stream_response_id,omitempty"`
Expand Down Expand Up @@ -1991,7 +1992,7 @@ func threadTranscriptItemView(value threadtranscript.Item) ThreadTranscriptItemV
Kind: string(value.Kind), Source: string(value.Source), Title: value.Title,
Detail: value.Detail, Status: value.Status, Verifiable: value.Verifiable,
InstructionAuthorized: value.InstructionAuthorized, AttemptID: value.AttemptID,
ModelAttempt: value.ModelAttempt, ToolRound: value.ToolRound,
ModelAttempt: value.ModelAttempt, Model: value.Model, ToolRound: value.ToolRound,
ToolName: value.ToolName, StreamResponseID: value.StreamResponseID,
StreamItemID: value.StreamItemID, StreamCallID: value.StreamCallID,
DurableCallID: value.DurableCallID, SourceRef: value.SourceRef,
Expand Down
25 changes: 21 additions & 4 deletions internal/runactivity/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ type Item struct {
InstructionAuthorized bool
AttemptID string
ModelAttempt int
Model string
ToolRound int
CreatedAt time.Time
}
Expand Down Expand Up @@ -117,7 +118,12 @@ func projectEvent(event events.Event) (Item, bool) {
return projectMessage(event, base)
case events.ModelStartedEvent:
base.Kind, base.Title, base.Status = KindModelCall, "模型调用开始", "running"
base.Detail = modelIdentity(event.PayloadJSON)
identity := modelIdentity(event.PayloadJSON)
base.Detail = identity
base.Model = identity
base.AttemptID = cleanLabel(stringField(event.PayloadJSON, "attempt_id"))
base.ModelAttempt = int(intField(event.PayloadJSON, "model_attempt"))
base.ToolRound = int(intField(event.PayloadJSON, "tool_round"))
if stringField(event.PayloadJSON, "purpose") == "context_compaction" {
base.Title = "正在整理上下文"
}
Expand All @@ -130,12 +136,24 @@ func projectEvent(event events.Event) (Item, bool) {
base.ToolRound = int(intField(event.PayloadJSON, "tool_round"))
case events.ModelCompletedEvent:
base.Kind, base.Title, base.Status = KindModelCall, "模型响应完成", "completed"
base.Detail = completedModelDetail(event.PayloadJSON)
identity := modelIdentity(event.PayloadJSON)
base.Model = identity
base.Detail = completedModelDetail(event.PayloadJSON, identity)
base.AttemptID = cleanLabel(stringField(event.PayloadJSON, "attempt_id"))
base.ModelAttempt = int(intField(event.PayloadJSON, "model_attempt"))
base.ToolRound = int(intField(event.PayloadJSON, "tool_round"))
if stringField(event.PayloadJSON, "purpose") == "context_compaction" {
base.Title = "上下文整理调用已结束"
}
case events.ModelFailedEvent:
base.Kind, base.Title, base.Status = KindModelCall, "模型调用失败", "failed"
identity := modelIdentity(event.PayloadJSON)
if identity != "" {
base.Model = identity
}
base.AttemptID = cleanLabel(stringField(event.PayloadJSON, "attempt_id"))
base.ModelAttempt = int(intField(event.PayloadJSON, "model_attempt"))
base.ToolRound = int(intField(event.PayloadJSON, "tool_round"))
if stringField(event.PayloadJSON, "purpose") == "context_compaction" {
base.Title = "上下文整理调用未完成"
}
Expand Down Expand Up @@ -865,8 +883,7 @@ func modelIdentity(payloadJSON string) string {
}
}

func completedModelDetail(payloadJSON string) string {
identity := modelIdentity(payloadJSON)
func completedModelDetail(payloadJSON string, identity string) string {
elapsed := intField(payloadJSON, "elapsed_millis")
var usage struct {
TotalTokens int `json:"total_tokens"`
Expand Down
3 changes: 3 additions & 0 deletions internal/runactivity/activity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ func TestBuildSeparatesPublicModelUpdatesFromHarnessEvents(t *testing.T) {
got.Items[3].Detail != "读取文件" || got.Items[3].Status != "" {
t.Fatalf("tool activity was not bounded to names: %#v", got.Items)
}
if got.Items[0].Model != "anthropic / test-model" {
t.Fatalf("model call record lost its provider/model identity: %#v", got.Items[0])
}
}

func TestBuildKeepsClosedThreadFailureVisibleAfterContinuation(t *testing.T) {
Expand Down
53 changes: 51 additions & 2 deletions internal/threadtranscript/transcript.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ type Item struct {
InstructionAuthorized bool
AttemptID string
ModelAttempt int
Model string
ToolRound int
ToolName string
StreamResponseID string
Expand Down Expand Up @@ -216,6 +217,14 @@ func Build(threadID string, source []Source) ([]Item, error) {
return nil, fmt.Errorf("thread transcript accepts at most %d source records", MaxSourceRecords)
}
ordered := append([]Source(nil), source...)
// The run's latest producing model call, so a durable reply projected from a
// SessionMessageEvent can inherit the model identity and attempt of the call it closed.
type producingModelCall struct {
attemptID string
modelAttempt int
model string
}
modelCallByRun := map[string]producingModelCall{}
slices.SortFunc(ordered, func(left, right Source) int {
if order := cmp.Compare(left.Ordinal, right.Ordinal); order != 0 {
return order
Expand Down Expand Up @@ -262,7 +271,47 @@ func Build(threadID string, source []Source) ([]Item, error) {
return nil, err
}
for _, projected := range projection.Items {
items = append(items, projectActivity(record, projected))
item := projectActivity(record, projected)
// The final durable reply is a SessionMessageEvent without attempt
// or model identity; inherit the run's latest producing model call so
// the web narrative attributes the reply to the exact model that generated it.
if projected.Kind == runactivity.KindModelCall {
call := modelCallByRun[record.RunID]
if projected.ModelAttempt != 0 && projected.ModelAttempt != call.modelAttempt {
call = producingModelCall{
attemptID: projected.AttemptID,
modelAttempt: projected.ModelAttempt,
model: projected.Model,
}
} else {
if projected.AttemptID != "" {
call.attemptID = projected.AttemptID
}
if projected.ModelAttempt != 0 {
call.modelAttempt = projected.ModelAttempt
}
if projected.Model != "" {
call.model = projected.Model
}
}
if call.attemptID != "" || call.model != "" {
modelCallByRun[record.RunID] = call
}
}
if item.Kind == runactivity.KindModelUpdate && item.Source == runactivity.SourceModel {
if call, ok := modelCallByRun[record.RunID]; ok {
if item.AttemptID == "" {
item.AttemptID = call.attemptID
}
if item.ModelAttempt == 0 {
item.ModelAttempt = call.modelAttempt
}
if item.Model == "" {
item.Model = call.model
}
}
}
items = append(items, item)
}
}
return items, nil
Expand Down Expand Up @@ -401,7 +450,7 @@ func projectActivity(source Source, projected runactivity.Item) Item {
Source: projected.Source, Title: projected.Title, Detail: projected.Detail,
Status: projected.Status, Verifiable: projected.Verifiable,
InstructionAuthorized: projected.InstructionAuthorized,
AttemptID: projected.AttemptID, ModelAttempt: projected.ModelAttempt,
AttemptID: projected.AttemptID, ModelAttempt: projected.ModelAttempt, Model: projected.Model,
ToolRound: projected.ToolRound, Durable: true, CreatedAt: projected.CreatedAt,
}
if (source.Event.Type == events.ThreadTurnFailedEvent && source.Event.Source == "thread_turn") ||
Expand Down
Loading
Loading