diff --git a/docs/openapi.json b/docs/openapi.json
index daec90d58..476d39637 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -73607,6 +73607,9 @@
],
"type": "string"
},
+ "model": {
+ "type": "string"
+ },
"model_attempt": {
"format": "int32",
"type": "integer"
diff --git a/docs/reviews/reply-model-attribution/README.md b/docs/reviews/reply-model-attribution/README.md
new file mode 100644
index 000000000..9b45aa2e9
--- /dev/null
+++ b/docs/reviews/reply-model-attribution/README.md
@@ -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 的历史项不显示徽标(不猜测)。
diff --git a/docs/reviews/reply-model-attribution/coverage.json b/docs/reviews/reply-model-attribution/coverage.json
new file mode 100644
index 000000000..52f6ef247
--- /dev/null
+++ b/docs/reviews/reply-model-attribution/coverage.json
@@ -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" }
+ ]
+}
diff --git a/internal/httpapi/views.go b/internal/httpapi/views.go
index a2c111d16..6d1d25e2a 100644
--- a/internal/httpapi/views.go
+++ b/internal/httpapi/views.go
@@ -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"`
@@ -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,
diff --git a/internal/runactivity/activity.go b/internal/runactivity/activity.go
index 866d205a5..b44744d26 100644
--- a/internal/runactivity/activity.go
+++ b/internal/runactivity/activity.go
@@ -60,6 +60,7 @@ type Item struct {
InstructionAuthorized bool
AttemptID string
ModelAttempt int
+ Model string
ToolRound int
CreatedAt time.Time
}
@@ -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 = "正在整理上下文"
}
@@ -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 = "上下文整理调用未完成"
}
@@ -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"`
diff --git a/internal/runactivity/activity_test.go b/internal/runactivity/activity_test.go
index 5d075b39a..28de10db7 100644
--- a/internal/runactivity/activity_test.go
+++ b/internal/runactivity/activity_test.go
@@ -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) {
diff --git a/internal/threadtranscript/transcript.go b/internal/threadtranscript/transcript.go
index 618b11928..5640177f3 100644
--- a/internal/threadtranscript/transcript.go
+++ b/internal/threadtranscript/transcript.go
@@ -92,6 +92,7 @@ type Item struct {
InstructionAuthorized bool
AttemptID string
ModelAttempt int
+ Model string
ToolRound int
ToolName string
StreamResponseID string
@@ -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
@@ -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
@@ -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") ||
diff --git a/internal/threadtranscript/transcript_test.go b/internal/threadtranscript/transcript_test.go
index 010305960..9bb9ca698 100644
--- a/internal/threadtranscript/transcript_test.go
+++ b/internal/threadtranscript/transcript_test.go
@@ -9,6 +9,138 @@ import (
"cyberagent-workbench/internal/events"
)
+func TestBuildAttributesDurableReplyWithLatestModelCallAttempt(t *testing.T) {
+ now := time.Date(2026, 10, 28, 1, 0, 0, 0, time.UTC)
+ source := []Source{
+ eventSource("run-1", 1, 1, events.ModelStartedEvent,
+ `{"provider":"anthropic","model":"test-model","attempt_id":"attempt-7"}`, now),
+ eventSource("run-1", 1, 2, events.ModelCompletedEvent,
+ `{"provider":"anthropic","model":"test-model","attempt_id":"attempt-7","elapsed_millis":1200,"usage":{"total_tokens":42}}`, now.Add(time.Second)),
+ eventSource("run-1", 1, 3, events.SessionMessageEvent,
+ `{"role":"assistant","content":"public answer","source_kind":"model_response","instruction_authorized":false}`, now.Add(2*time.Second)),
+ eventSource("run-1", 1, 4, events.ModelPublicCommentaryEvent,
+ `{"text":"检查中","attempt_id":"attempt-7"}`, now.Add(3*time.Second)),
+ }
+ items, err := Build("thread-1", source)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var call, reply, commentary *Item
+ for index := range items {
+ switch items[index].ID {
+ case "event-run-1-model.started":
+ call = &items[index]
+ case "event-run-1-session.message_created":
+ reply = &items[index]
+ case "event-run-1-model.public_commentary":
+ commentary = &items[index]
+ }
+ }
+ if call == nil || reply == nil || commentary == nil {
+ t.Fatalf("expected call, reply and commentary items: %#v", items)
+ }
+ if call.Model != "anthropic / test-model" || call.AttemptID != "attempt-7" {
+ t.Fatalf("model call lost its identity or attempt: %#v", call)
+ }
+ if reply.AttemptID != "attempt-7" || reply.Model != "anthropic / test-model" {
+ t.Fatalf("durable reply did not inherit the call attempt or model: %#v", reply)
+ }
+ if commentary.AttemptID != "attempt-7" {
+ t.Fatalf("commentary lost its attempt: %#v", commentary)
+ }
+}
+
+func TestBuildAttributesDurableReplyWithMultipleModelAttempts(t *testing.T) {
+ now := time.Date(2026, 10, 28, 2, 0, 0, 0, time.UTC)
+ // Realistic supervisor event sequence:
+ // In the same supervisor turn (attempt_id: "attempt-1"), model_attempt=1 with Claude
+ // is attempted first and fails. The supervisor retries with model_attempt=2 with Gemini,
+ // which completes successfully and emits the final assistant reply.
+ source := []Source{
+ eventSource("run-1", 1, 1, events.ModelStartedEvent,
+ `{"provider":"anthropic","model":"claude-3-5-sonnet","attempt_id":"attempt-1","model_attempt":1}`, now),
+ eventSource("run-1", 1, 2, events.ModelFailedEvent,
+ `{"provider":"anthropic","model":"claude-3-5-sonnet","attempt_id":"attempt-1","model_attempt":1,"error":"rate_limited"}`, now.Add(time.Second)),
+ eventSource("run-1", 1, 3, events.ModelStartedEvent,
+ `{"provider":"google","model":"gemini-1.5-pro","attempt_id":"attempt-1","model_attempt":2}`, now.Add(2*time.Second)),
+ eventSource("run-1", 1, 4, events.ModelCompletedEvent,
+ `{"provider":"google","model":"gemini-1.5-pro","attempt_id":"attempt-1","model_attempt":2,"elapsed_millis":900,"usage":{"total_tokens":55}}`, now.Add(3*time.Second)),
+ eventSource("run-1", 1, 5, events.SessionMessageEvent,
+ `{"role":"assistant","content":"final answer from gemini","source_kind":"model_response","instruction_authorized":false}`, now.Add(4*time.Second)),
+ eventSource("run-1", 1, 6, events.ModelPublicCommentaryEvent,
+ `{"text":"已完成","attempt_id":"attempt-1"}`, now.Add(5*time.Second)),
+ }
+ items, err := Build("thread-1", source)
+ if err != nil {
+ t.Fatalf("Build failed: %v", err)
+ }
+ var reply *Item
+ for index := range items {
+ if items[index].ID == "event-run-1-session.message_created" {
+ reply = &items[index]
+ break
+ }
+ }
+ if reply == nil {
+ t.Fatalf("expected session message item in items: %#v", items)
+ }
+ if reply.AttemptID != "attempt-1" {
+ t.Fatalf("expected AttemptID 'attempt-1', got %q", reply.AttemptID)
+ }
+ if reply.ModelAttempt != 2 {
+ t.Fatalf("expected ModelAttempt 2, got %d", reply.ModelAttempt)
+ }
+ if reply.Model != "google / gemini-1.5-pro" {
+ t.Fatalf("expected Model 'google / gemini-1.5-pro', got %q", reply.Model)
+ }
+}
+
+func TestBuildAttributesDurableRepliesAcrossMultipleTurns(t *testing.T) {
+ now := time.Date(2026, 10, 28, 3, 0, 0, 0, time.UTC)
+ // Multi-turn run sequence:
+ // Turn 1 (attempt-1): model_attempt 1 (Claude) fails, model_attempt 2 (Gemini) succeeds -> reply 1
+ // Turn 2 (attempt-2): model_attempt 1 (Claude) succeeds -> reply 2
+ source := []Source{
+ eventSource("run-1", 1, 1, events.ModelStartedEvent,
+ `{"provider":"anthropic","model":"claude-3-5-sonnet","attempt_id":"attempt-1","model_attempt":1}`, now),
+ eventSource("run-1", 1, 2, events.ModelFailedEvent,
+ `{"provider":"anthropic","model":"claude-3-5-sonnet","attempt_id":"attempt-1","model_attempt":1,"error":"timeout"}`, now.Add(time.Second)),
+ eventSource("run-1", 1, 3, events.ModelStartedEvent,
+ `{"provider":"google","model":"gemini-1.5-pro","attempt_id":"attempt-1","model_attempt":2}`, now.Add(2*time.Second)),
+ eventSource("run-1", 1, 4, events.ModelCompletedEvent,
+ `{"provider":"google","model":"gemini-1.5-pro","attempt_id":"attempt-1","model_attempt":2,"elapsed_millis":800}`, now.Add(3*time.Second)),
+ eventSource("run-1", 1, 5, events.SessionMessageEvent,
+ `{"role":"assistant","content":"turn 1 answer from gemini","source_kind":"model_response","instruction_authorized":false}`, now.Add(4*time.Second)),
+ eventSource("run-1", 1, 6, events.ModelStartedEvent,
+ `{"provider":"anthropic","model":"claude-3-5-sonnet","attempt_id":"attempt-2","model_attempt":1}`, now.Add(5*time.Second)),
+ eventSource("run-1", 1, 7, events.ModelCompletedEvent,
+ `{"provider":"anthropic","model":"claude-3-5-sonnet","attempt_id":"attempt-2","model_attempt":1,"elapsed_millis":1100}`, now.Add(6*time.Second)),
+ eventSource("run-1", 1, 8, events.SessionMessageEvent,
+ `{"role":"assistant","content":"turn 2 answer from claude","source_kind":"model_response","instruction_authorized":false}`, now.Add(7*time.Second)),
+ }
+ items, err := Build("thread-1", source)
+ if err != nil {
+ t.Fatalf("Build failed: %v", err)
+ }
+ var replies []*Item
+ for index := range items {
+ if items[index].Kind == "model_update" && items[index].Source == "model" {
+ replies = append(replies, &items[index])
+ }
+ }
+ if len(replies) != 2 {
+ t.Fatalf("expected 2 assistant reply items, got %d: %#v", len(replies), items)
+ }
+ // Turn 1 reply
+ if replies[0].AttemptID != "attempt-1" || replies[0].ModelAttempt != 2 || replies[0].Model != "google / gemini-1.5-pro" {
+ t.Fatalf("turn 1 reply attribution mismatch: %#v", replies[0])
+ }
+ // Turn 2 reply
+ if replies[1].AttemptID != "attempt-2" || replies[1].ModelAttempt != 1 || replies[1].Model != "anthropic / claude-3-5-sonnet" {
+ t.Fatalf("turn 2 reply attribution mismatch: %#v", replies[1])
+ }
+}
+
func TestBuildOrdersRunBoundariesMessagesAndStructuredToolStages(t *testing.T) {
now := time.Date(2026, 8, 24, 1, 2, 3, 0, time.UTC)
batch, _ := json.Marshal(map[string]any{
diff --git a/web/e2e/agent-browser.spec.ts b/web/e2e/agent-browser.spec.ts
index f376964b0..ac3cc8906 100644
--- a/web/e2e/agent-browser.spec.ts
+++ b/web/e2e/agent-browser.spec.ts
@@ -39,7 +39,7 @@ test("native Agent Browser uses the production API and visible workbench", async
await expect(dialog).toBeHidden();
await page.getByRole("textbox", { name: "开始新对话" }).fill("JOURNEY_BROWSER_SETUP: Read README.md and wait for the operator to confirm browser access.");
await page.getByRole("button", { name: "发送消息", exact: true }).click();
- await expect(page.getByText(/JOURNEY_BROWSER_SETUP_READY/u)).toBeVisible();
+ await expect(page.getByText(/JOURNEY_BROWSER_SETUP_READY/u)).toBeVisible({ timeout: nativeMac ? 60_000 : 30_000 });
await page.getByRole("button", { name: "请求批准", exact: true }).click();
await page.getByRole("menuitemradio", { name: "完全访问权限", exact: true }).click();
await page.getByRole("button", { name: "确认启用", exact: true }).click();
diff --git a/web/src/api/client/responses/transcripts.ts b/web/src/api/client/responses/transcripts.ts
index 7e350ae51..7df842acd 100644
--- a/web/src/api/client/responses/transcripts.ts
+++ b/web/src/api/client/responses/transcripts.ts
@@ -75,7 +75,7 @@ export function parseThreadTranscriptItem(value: unknown): ThreadTranscriptItemV
"run_ordinal", "sequence", "source", "stage", "title", "verifiable", "version"];
const optional = ["activity_detail_ref", "activity_summary", "attempt_id", "boundary_reason", "detail",
"detail_available", "delivery_mode", "durable_call_id", "promoted_to_message_id", "promoted_from_message_id",
- "model_attempt", "position", "source_ref", "status", "stream_call_id", "stream_item_id",
+ "model", "model_attempt", "position", "source_ref", "status", "stream_call_id", "stream_item_id",
"stream_response_id", "tool_name", "tool_round", "web_evidence", "images", "attachments", "peer_message"];
if (!isRecord(value) || !hasOnlyKeys(value, [...required, ...optional]) ||
required.some((key) => !Object.prototype.hasOwnProperty.call(value, key)) ||
@@ -103,7 +103,8 @@ export function parseThreadTranscriptItem(value: unknown): ThreadTranscriptItemV
value.detail.length === 0 || value.detail.length > 16_384 || value.detail.includes("\0")) ||
value.position !== undefined && !safePositiveInteger(value.position) ||
value.model_attempt !== undefined && !safePositiveInteger(value.model_attempt) ||
- value.tool_round !== undefined && !safeBoundedCount(value.tool_round, 100)) {
+ value.tool_round !== undefined && !safeBoundedCount(value.tool_round, 100) ||
+ (value.model !== undefined && (typeof value.model !== "string" || !boundedText(value.model, 256)))) {
throw new APIRequestError("Thread transcript metadata is invalid", "INVALID_RESPONSE", 502);
}
if ((value.sequence === 0) !== (value.boundary_reason !== undefined) ||
diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts
index 5940d328a..81e97c7d1 100644
--- a/web/src/api/schema.d.ts
+++ b/web/src/api/schema.d.ts
@@ -15521,6 +15521,7 @@ export interface components {
instruction_authorized: boolean;
/** @enum {string} */
kind: "harness_status" | "model_update" | "operator_input" | "model_call" | "tool_call" | "approval" | "file_change" | "plan" | "dependency" | "browser" | "peer_message";
+ model?: string;
/** Format: int32 */
model_attempt?: number;
peer_message?: components["schemas"]["ThreadPeerMessage"];
diff --git a/web/src/v2/components/narrative.test.tsx b/web/src/v2/components/narrative.test.tsx
index 363e71345..9dae5bdc5 100644
--- a/web/src/v2/components/narrative.test.tsx
+++ b/web/src/v2/components/narrative.test.tsx
@@ -137,3 +137,22 @@ it("waits for a saved reply before exposing branch actions", () => {
view.rerender();
expect(screen.getByRole("button", { name: "Branch into new chat" })).toBeDisabled();
});
+
+it("shows the producing model as muted attribution on durable replies only", () => {
+ const entries: NarrativeEntry[] = [
+ { id: "reply", kind: "assistant", text: "Attributed reply", model: "anthropic / test-model",
+ createdAt: "2026-10-28T00:00:00Z", provisional: false },
+ { id: "bare", kind: "assistant", text: "Unattributed reply", createdAt: "2026-10-28T00:00:00Z", provisional: false },
+ ];
+ const { container } = render();
+ const badges = container.querySelectorAll(".v2-assistant-model");
+ expect(badges).toHaveLength(1);
+ expect(badges[0].textContent).toBe("anthropic / test-model");
+ const streaming: NarrativeEntry[] = [
+ { id: "live", kind: "assistant", text: "Streaming", model: "anthropic / test-model",
+ createdAt: "2026-10-28T00:00:00Z", provisional: true },
+ ];
+ cleanup();
+ const live = render();
+ expect(live.container.querySelectorAll(".v2-assistant-model")).toHaveLength(0);
+});
diff --git a/web/src/v2/components/narrative.tsx b/web/src/v2/components/narrative.tsx
index 32d602791..878989f17 100644
--- a/web/src/v2/components/narrative.tsx
+++ b/web/src/v2/components/narrative.tsx
@@ -97,7 +97,7 @@ function MessageTime({ createdAt }: { createdAt: string }) {
aria-label={t("消息时间:", "Message time: ") + full}>{formats.short.format(date)};
}
-function AssistantMessageActions({ text, createdAt, branchSourceID, onBranch, user = false, onCompose, loadText }: {
+function AssistantMessageActions({ text, createdAt, branchSourceID, onBranch, user = false, onCompose, loadText, model }: {
text: string;
createdAt: string;
branchSourceID?: string;
@@ -105,6 +105,7 @@ function AssistantMessageActions({ text, createdAt, branchSourceID, onBranch, us
user?: boolean;
onCompose?: (text: string) => void;
loadText?: () => Promise;
+ model?: string;
}) {
const { t } = useLocale();
const [copied, setCopied] = useState<"idle" | "success" | "error">("idle");
@@ -194,6 +195,7 @@ function AssistantMessageActions({ text, createdAt, branchSourceID, onBranch, us
{branching ? : }
{branchLabel}
+ {model && {model}}
{branchError && {branchError}
}>;
}
@@ -274,7 +276,7 @@ const NarrativeRow = memo(function NarrativeRow({ client, entry, threadID, onOpe
{!entry.provisional && entry.changeSourceID && entry.runId && typeof client.threadChanges === "function" &&
}
- {!entry.provisional && }
+ {!entry.provisional && }
;
if (entry.kind === "activity") return
;
diff --git a/web/src/v2/projection/narrative.test.ts b/web/src/v2/projection/narrative.test.ts
index 437a0e97f..5229fbbb9 100644
--- a/web/src/v2/projection/narrative.test.ts
+++ b/web/src/v2/projection/narrative.test.ts
@@ -595,3 +595,91 @@ it("localizes generated activity labels while preserving original messages, comm
snapshot: snapshot({ text: "", items: [{ id: "tool-1", type: "tool_call", tool_name: "command_runtime", status: "running", argument_bytes: 12, durable: false, provisional: true, response_id: "response-1" }] }) });
expect(live[0]).toMatchObject({ kind: "activity", title: "Run command", detail: "Preparing tool request · 12 bytes" });
});
+
+it("attributes a durable assistant reply to the model recorded for its attempt", () => {
+ const transcript: ThreadTranscriptActivityItem[] = [
+ item({ id: "call-1", canonical_id: "call-1", sequence: 1, source: "harness", kind: "model_call",
+ activity_type: "execute", attempt_id: "attempt-7", model: "anthropic / test-model",
+ title: "模型调用开始", detail: "anthropic / test-model", status: "running" }),
+ item({ id: "reply-1", canonical_id: "reply-1", sequence: 2, source: "model", kind: "model_update",
+ activity_type: "message", attempt_id: "attempt-7", title: "更新", detail: "我会先检查相关实现。" }),
+ ];
+ const projected = projectThreadNarrative(transcript);
+ expect(projected.filter((entry) => entry.kind === "assistant"))
+ .toEqual([expect.objectContaining({ model: "anthropic / test-model" })]);
+
+ // A reply from another attempt keeps its own attribution, including none.
+ const mixed = projectThreadNarrative([...transcript, item({
+ id: "reply-2", canonical_id: "reply-2", sequence: 3, source: "model", kind: "model_update",
+ activity_type: "message", attempt_id: "attempt-9", title: "更新", detail: "第二条。", run_id: "run-2",
+ run_ordinal: 2 })]);
+ expect(mixed.at(-1)).toMatchObject({ kind: "assistant" });
+ expect(mixed.at(-1)).not.toHaveProperty("model");
+});
+
+it("attributes to the actual producing model when multiple model attempts share one supervisor attempt", () => {
+ const transcript: ThreadTranscriptActivityItem[] = [
+ item({ id: "call-1", canonical_id: "call-1", sequence: 1, source: "harness", kind: "model_call",
+ activity_type: "execute", attempt_id: "attempt-1", model_attempt: 1, model: "anthropic / claude",
+ title: "模型调用开始", detail: "anthropic / claude", status: "failed" }),
+ item({ id: "call-2", canonical_id: "call-2", sequence: 2, source: "harness", kind: "model_call",
+ activity_type: "execute", attempt_id: "attempt-1", model_attempt: 2, model: "google / gemini",
+ title: "模型响应", detail: "google / gemini", status: "completed" }),
+ item({ id: "reply-1", canonical_id: "reply-1", sequence: 3, source: "model", kind: "model_update",
+ activity_type: "message", attempt_id: "attempt-1", model_attempt: 2, model: "google / gemini",
+ title: "答复", detail: "来自 Gemini 的回复" }),
+ ];
+ const projected = projectThreadNarrative(transcript);
+ expect(projected.filter((entry) => entry.kind === "assistant"))
+ .toEqual([expect.objectContaining({ model: "google / gemini" })]);
+
+ // Fallback attribution without direct item.model picks the specific model_attempt
+ const fallbackTranscript: ThreadTranscriptActivityItem[] = [
+ item({ id: "call-1", canonical_id: "call-1", sequence: 1, source: "harness", kind: "model_call",
+ activity_type: "execute", attempt_id: "attempt-1", model_attempt: 1, model: "anthropic / claude",
+ title: "模型调用开始", detail: "anthropic / claude", status: "failed" }),
+ item({ id: "call-2", canonical_id: "call-2", sequence: 2, source: "harness", kind: "model_call",
+ activity_type: "execute", attempt_id: "attempt-1", model_attempt: 2, model: "google / gemini",
+ title: "模型响应", detail: "google / gemini", status: "completed" }),
+ item({ id: "reply-1", canonical_id: "reply-1", sequence: 3, source: "model", kind: "model_update",
+ activity_type: "message", attempt_id: "attempt-1", model_attempt: 2,
+ title: "答复", detail: "来自 Gemini 的回复" }),
+ ];
+ const fallbackProjected = projectThreadNarrative(fallbackTranscript);
+ expect(fallbackProjected.filter((entry) => entry.kind === "assistant"))
+ .toEqual([expect.objectContaining({ model: "google / gemini" })]);
+
+ // Strict fallback boundary: if a reply lacks both item.model and model_attempt,
+ // and the attempt contained multiple different models, do not guess; omit the badge.
+ const ambiguousTranscript: ThreadTranscriptActivityItem[] = [
+ item({ id: "call-1", canonical_id: "call-1", sequence: 1, source: "harness", kind: "model_call",
+ activity_type: "execute", attempt_id: "attempt-1", model_attempt: 1, model: "anthropic / claude",
+ title: "模型调用开始", detail: "anthropic / claude", status: "failed" }),
+ item({ id: "call-2", canonical_id: "call-2", sequence: 2, source: "harness", kind: "model_call",
+ activity_type: "execute", attempt_id: "attempt-1", model_attempt: 2, model: "google / gemini",
+ title: "模型响应", detail: "google / gemini", status: "completed" }),
+ item({ id: "reply-ambiguous", canonical_id: "reply-ambiguous", sequence: 3, source: "model", kind: "model_update",
+ activity_type: "message", attempt_id: "attempt-1",
+ title: "答复", detail: "无明确模型尝试标识的回复" }),
+ ];
+ const ambiguousProjected = projectThreadNarrative(ambiguousTranscript);
+ expect(ambiguousProjected.find((entry) => entry.kind === "assistant" && entry.id === "reply-ambiguous"))
+ .not.toHaveProperty("model");
+
+ // Strict fallback boundary: if all model calls within the attempt used the same model,
+ // the bare attempt_id fallback is uniquely provable and safely displays the model.
+ const singleModelTranscript: ThreadTranscriptActivityItem[] = [
+ item({ id: "call-1", canonical_id: "call-1", sequence: 1, source: "harness", kind: "model_call",
+ activity_type: "execute", attempt_id: "attempt-1", model_attempt: 1, model: "google / gemini",
+ title: "模型调用开始", detail: "google / gemini", status: "failed" }),
+ item({ id: "call-2", canonical_id: "call-2", sequence: 2, source: "harness", kind: "model_call",
+ activity_type: "execute", attempt_id: "attempt-1", model_attempt: 2, model: "google / gemini",
+ title: "模型响应", detail: "google / gemini", status: "completed" }),
+ item({ id: "reply-single", canonical_id: "reply-single", sequence: 3, source: "model", kind: "model_update",
+ activity_type: "message", attempt_id: "attempt-1",
+ title: "答复", detail: "唯一确定模型的回复" }),
+ ];
+ const singleModelProjected = projectThreadNarrative(singleModelTranscript);
+ expect(singleModelProjected.find((entry) => entry.kind === "assistant" && entry.id === "reply-single"))
+ .toMatchObject({ model: "google / gemini" });
+});
diff --git a/web/src/v2/projection/narrative.ts b/web/src/v2/projection/narrative.ts
index bf4549e78..5d3b3c696 100644
--- a/web/src/v2/projection/narrative.ts
+++ b/web/src/v2/projection/narrative.ts
@@ -60,6 +60,10 @@ export type NarrativeEntry =
runId?: string;
branchSourceID?: string;
changeSourceID?: string;
+ // Which provider/model produced this reply, joined from the attempt's
+ // model-call record. Mid-thread model switching (#470) makes the
+ // attribution otherwise ambiguous.
+ model?: string;
createdAt: string;
provisional: boolean;
}
@@ -213,6 +217,25 @@ export function projectThreadNarrative(
): NarrativeEntry[] {
const result: NarrativeEntry[] = [];
const noticeEvents = new Set();
+ // Durable model-call records carry provider/model identities.
+ // Index them by specific attempt (${attempt_id}:${model_attempt}) and by attempt_id.
+ // Fallback attribution by bare attempt_id is strictly guarded: it is only used
+ // if the attempt uniquely and unambiguously resolves to exactly one model identity.
+ const modelBySpecificAttempt = new Map();
+ const modelsByAttempt = new Map>();
+ for (const item of transcript) {
+ if (item.model && item.attempt_id) {
+ if (item.model_attempt) {
+ modelBySpecificAttempt.set(`${item.attempt_id}:${item.model_attempt}`, item.model);
+ }
+ const existing = modelsByAttempt.get(item.attempt_id);
+ if (existing) {
+ existing.add(item.model);
+ } else {
+ modelsByAttempt.set(item.attempt_id, new Set([item.model]));
+ }
+ }
+ }
type ActivityEntry = Extract;
type ActivityLeaf = ActivityEntry["items"][number];
const canonicalActivities = new Map p { color: var(--v2-muted); line-height: 1.5; }
.model-optional-prices { border-top: 1px solid var(--v2-border); margin-top: 20px; padding-top: 14px; }
.model-optional-prices summary { color: var(--v2-ink); cursor: pointer; font-weight: 600; }
-
.v2-prompt-command-menu { position:relative; display:grid; gap:2px; margin:0 12px 4px; padding:6px; border:1px solid var(--v2-border); border-radius:10px; background:var(--prayu-surface-solid); max-height:240px; overflow:auto; }
.v2-prompt-command-item, .v2-prompt-command-manage { display:flex; align-items:center; gap:8px; width:100%; padding:6px 8px; border:0; border-radius:8px; background:transparent; color:inherit; font:inherit; font-size:13px; text-align:left; cursor:pointer; }
.v2-prompt-command-item:hover, .v2-prompt-command-item:focus-visible, .v2-prompt-command-manage:hover { background:var(--v2-control-hover); outline:none; }
@@ -2844,3 +2843,5 @@
.v2-prompt-command-list li { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:6px 8px; border:1px solid var(--v2-border); border-radius:8px; }
.v2-prompt-command-actions { display:flex; gap:6px; }
.v2-prompt-command-actions button { padding:4px 8px; border:1px solid var(--v2-border); border-radius:6px; background:transparent; color:inherit; cursor:pointer; }
+
+.v2-assistant-model { margin-left:auto; padding:2px 8px; border:1px solid var(--v2-border); border-radius:6px; color:var(--v2-muted); font-size:11px; line-height:1.4; overflow-wrap:anywhere; }