From b850ef8b9e5160591d842a003b03501761f607b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 04:43:23 +0800 Subject: [PATCH 1/5] feat(web): attribute each durable assistant reply to its producing model Model switching became a first-class mid-thread action (#470), but the transcript never says which model produced which reply. Thread the model identity (provider / model, already recorded on model-call events) through the activity projection, the thread transcript, and the HTTP view as an additive omitempty field, then join it per attempt in the web narrative projection and show it as muted attribution on durable replies. No behavior change beyond the new read-only field and badge; live streaming entries stay unattributed until their call record is durable. --- docs/openapi.json | 3 +++ internal/httpapi/views.go | 3 ++- internal/runactivity/activity.go | 3 +++ internal/runactivity/activity_test.go | 3 +++ internal/threadtranscript/transcript.go | 3 ++- web/src/api/schema.d.ts | 1 + web/src/v2/components/narrative.test.tsx | 19 +++++++++++++++++++ web/src/v2/components/narrative.tsx | 6 ++++-- web/src/v2/projection/narrative.test.ts | 21 +++++++++++++++++++++ web/src/v2/projection/narrative.ts | 15 +++++++++++++++ web/src/v2/styles.css | 3 ++- 11 files changed, 75 insertions(+), 5 deletions(-) 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/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..02d0f0f8e 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 } @@ -118,6 +119,7 @@ func projectEvent(event events.Event) (Item, bool) { case events.ModelStartedEvent: base.Kind, base.Title, base.Status = KindModelCall, "模型调用开始", "running" base.Detail = modelIdentity(event.PayloadJSON) + base.Model = modelIdentity(event.PayloadJSON) if stringField(event.PayloadJSON, "purpose") == "context_compaction" { base.Title = "正在整理上下文" } @@ -131,6 +133,7 @@ func projectEvent(event events.Event) (Item, bool) { case events.ModelCompletedEvent: base.Kind, base.Title, base.Status = KindModelCall, "模型响应完成", "completed" base.Detail = completedModelDetail(event.PayloadJSON) + base.Model = modelIdentity(event.PayloadJSON) if stringField(event.PayloadJSON, "purpose") == "context_compaction" { base.Title = "上下文整理调用已结束" } 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..ff86479e7 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 @@ -401,7 +402,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/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 ?