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 ?