From 5caa5d133d11d954a27d3c97c5ffd4c3c4732ee4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:08:58 +0000 Subject: [PATCH 01/16] fix: keep chat prompt attachments through a hook override A user_prompt_submit input_override replaced every submitted part with one text part, so file uploads and file references disappeared from the prompt and uploads stopped being linked to the chat. The consumer only ever sees concatenated text in the event's prompt field, so the override now answers for text alone and non-text parts survive. Consumers that must block an attachment inspect parts and deny the submission. --- coderd/exp_chats_hooks_test.go | 35 ++++++++--- coderd/x/chatd/chathooks/effects.go | 23 ++++++- .../x/chatd/chathooks/hooks_internal_test.go | 62 +++++++++++++++++++ coderd/x/chatd/hooks_test.go | 11 +++- docs/admin/setup/chat-lifecycle-hooks.md | 2 + 5 files changed, 121 insertions(+), 12 deletions(-) diff --git a/coderd/exp_chats_hooks_test.go b/coderd/exp_chats_hooks_test.go index f1f1db93a58..f0a67827e98 100644 --- a/coderd/exp_chats_hooks_test.go +++ b/coderd/exp_chats_hooks_test.go @@ -410,19 +410,20 @@ func TestChatHooksFileLinksAfterPromptOverride(t *testing.T) { return resp.ID } - redactedFile := uploadFile("redacted.png") + createFile := uploadFile("create.png") chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: user.OrganizationID, ModelConfigID: &model.ID, Content: []codersdk.ChatInputPart{ {Type: codersdk.ChatInputPartTypeText, Text: "REDACTME create"}, - {Type: codersdk.ChatInputPartTypeFile, FileID: redactedFile}, + {Type: codersdk.ChatInputPartTypeFile, FileID: createFile}, }, }) require.NoError(t, err) created, err := client.GetChat(ctx, chat.ID) require.NoError(t, err) - require.Empty(t, created.Files, "overridden create must not link dropped attachments") + require.Len(t, created.Files, 1, "an overridden create must keep linking its attachments") + require.Equal(t, createFile, created.Files[0].ID) coderdtest.WaitForChatSettled(ctx, t, api, chat.ID) @@ -437,23 +438,37 @@ func TestChatHooksFileLinksAfterPromptOverride(t *testing.T) { require.False(t, sendResp.Queued) afterSend, err := client.GetChat(ctx, chat.ID) require.NoError(t, err) - require.Len(t, afterSend.Files, 1) - require.Equal(t, keptFile, afterSend.Files[0].ID) + require.Len(t, afterSend.Files, 2) + require.ElementsMatch(t, []uuid.UUID{createFile, keptFile}, []uuid.UUID{afterSend.Files[0].ID, afterSend.Files[1].ID}) coderdtest.WaitForChatSettled(ctx, t, api, chat.ID) - droppedFile := uploadFile("dropped.png") - _, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + overriddenFile := uploadFile("overridden.png") + sendResp, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ Content: []codersdk.ChatInputPart{ + {Type: codersdk.ChatInputPartTypeFileReference, FileName: "main.go", StartLine: 1, EndLine: 3, Content: "package main"}, {Type: codersdk.ChatInputPartTypeText, Text: "REDACTME send"}, - {Type: codersdk.ChatInputPartTypeFile, FileID: droppedFile}, + {Type: codersdk.ChatInputPartTypeFile, FileID: overriddenFile}, }, }) require.NoError(t, err) + require.False(t, sendResp.Queued) afterOverride, err := client.GetChat(ctx, chat.ID) require.NoError(t, err) - require.Len(t, afterOverride.Files, 1, "overridden send must not link dropped attachments") - require.Equal(t, keptFile, afterOverride.Files[0].ID) + require.Len(t, afterOverride.Files, 3, "an overridden send must keep linking its attachments") + require.ElementsMatch(t, []uuid.UUID{createFile, keptFile, overriddenFile}, []uuid.UUID{ + afterOverride.Files[0].ID, + afterOverride.Files[1].ID, + afterOverride.Files[2].ID, + }) + + require.NotNil(t, sendResp.Message) + require.Equal(t, codersdk.ChatMessageRoleUser, sendResp.Message.Role) + require.Equal(t, []codersdk.ChatMessagePart{ + codersdk.ChatMessageFileReference("main.go", 1, 3, "package main"), + codersdk.ChatMessageText("redacted"), + codersdk.ChatMessageFile(overriddenFile, "image/png", "overridden.png"), + }, sendResp.Message.Content) } func TestChatHookNoticeMessagesInResponses(t *testing.T) { diff --git a/coderd/x/chatd/chathooks/effects.go b/coderd/x/chatd/chathooks/effects.go index e0f82eb26b2..d2f171f0d2d 100644 --- a/coderd/x/chatd/chathooks/effects.go +++ b/coderd/x/chatd/chathooks/effects.go @@ -156,6 +156,27 @@ func UserPromptParts(result *Result) []codersdk.ChatMessagePart { return parts } +// The hook's prompt field contains only concatenated text, so an override +// replaces text parts while preserving non-text parts for policy inspection. +func applyPromptOverride(parts []codersdk.ChatMessagePart, override string) []codersdk.ChatMessagePart { + userParts := make([]codersdk.ChatMessagePart, 0, len(parts)+1) + replaced := false + for _, part := range parts { + if part.Type != codersdk.ChatMessagePartTypeText { + userParts = append(userParts, part) + continue + } + if !replaced { + userParts = append(userParts, codersdk.ChatMessageText(override)) + replaced = true + } + } + if !replaced { + userParts = append(userParts, codersdk.ChatMessageText(override)) + } + return userParts +} + // ComposeUserPromptContent applies a user_prompt_submit result to the // submitted parts. The merge order is fixed: override-or-original user // parts first, then hook-context, then hook-notice. The composite @@ -167,7 +188,7 @@ func ComposeUserPromptContent(parts []codersdk.ChatMessagePart, result *Result) } userParts := parts if overridden { - userParts = []codersdk.ChatMessagePart{codersdk.ChatMessageText(override)} + userParts = applyPromptOverride(parts, override) } hookParts := UserPromptParts(result) if len(hookParts) == 0 { diff --git a/coderd/x/chatd/chathooks/hooks_internal_test.go b/coderd/x/chatd/chathooks/hooks_internal_test.go index eae7a640327..58a8dab9ad0 100644 --- a/coderd/x/chatd/chathooks/hooks_internal_test.go +++ b/coderd/x/chatd/chathooks/hooks_internal_test.go @@ -18,6 +18,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/x/agenthooks/dispatch" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/x/agenthooks" "github.com/coder/coder/v2/testutil" ) @@ -273,3 +274,64 @@ func TestEventMessagesSkipsBlankModelContext(t *testing.T) { require.Len(t, messages, 1) require.Equal(t, database.ChatMessageVisibilityModel, messages[0].Visibility) } + +func TestComposeUserPromptContentOverride(t *testing.T) { + t.Parallel() + + text := codersdk.ChatMessageText("original") + reference := codersdk.ChatMessageFileReference("main.go", 1, 3, "package main") + upload := codersdk.ChatMessageFile(uuid.New(), "image/png", "shot.png") + override := &Result{InputOverride: json.RawMessage(`{"prompt":"replacement"}`)} + + t.Run("ReplacesTextInPlaceAndKeepsAttachments", func(t *testing.T) { + t.Parallel() + + parts, overridden, err := ComposeUserPromptContent([]codersdk.ChatMessagePart{reference, text, upload}, override) + require.NoError(t, err) + require.True(t, overridden) + require.Equal(t, []codersdk.ChatMessagePart{ + reference, + codersdk.ChatMessageText("replacement"), + upload, + }, parts) + }) + + t.Run("CollapsesEveryTextPart", func(t *testing.T) { + t.Parallel() + + parts, overridden, err := ComposeUserPromptContent([]codersdk.ChatMessagePart{ + text, + upload, + codersdk.ChatMessageText("trailing"), + }, override) + require.NoError(t, err) + require.True(t, overridden) + require.Equal(t, []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("replacement"), + upload, + }, parts) + }) + + t.Run("AppendsWhenSubmissionHasNoText", func(t *testing.T) { + t.Parallel() + + parts, overridden, err := ComposeUserPromptContent([]codersdk.ChatMessagePart{upload}, override) + require.NoError(t, err) + require.True(t, overridden) + require.Equal(t, []codersdk.ChatMessagePart{upload, codersdk.ChatMessageText("replacement")}, parts) + }) + + t.Run("KeepsSubmittedPartsWithoutOverride", func(t *testing.T) { + t.Parallel() + + submitted := []codersdk.ChatMessagePart{text, upload} + parts, overridden, err := ComposeUserPromptContent(submitted, &Result{UserMessage: "notice"}) + require.NoError(t, err) + require.False(t, overridden) + require.Equal(t, []codersdk.ChatMessagePart{ + text, + upload, + {Type: codersdk.ChatMessagePartTypeHookNotice, Text: "notice"}, + }, parts) + }) +} diff --git a/coderd/x/chatd/hooks_test.go b/coderd/x/chatd/hooks_test.go index ee445e56022..af43976da2d 100644 --- a/coderd/x/chatd/hooks_test.go +++ b/coderd/x/chatd/hooks_test.go @@ -74,6 +74,7 @@ func TestSendMessageUserPromptSubmitHook(t *testing.T) { require.NoError(t, err) require.Equal(t, []codersdk.ChatMessagePart{ codersdk.ChatMessageText("after"), + codersdk.ChatMessageFileReference("main.go", 1, 3, "package main"), {Type: codersdk.ChatMessagePartTypeHookContext, Text: "model only"}, {Type: codersdk.ChatMessagePartTypeHookNotice, Text: "user only"}, }, parts) @@ -475,17 +476,25 @@ func TestEditMessageUserPromptSubmitHook(t *testing.T) { t.Cleanup(consumer.Close) server := newHookTestServer(t, db, ps, consumer) + upload := codersdk.ChatMessageFile(uuid.New(), "image/png", "edited.png") + reference := codersdk.ChatMessageFileReference("main.go", 1, 3, "package main") result, err := server.EditMessage(ctx, chatd.EditMessageOptions{ ChatID: chat.ID, CreatedBy: user.ID, EditedMessageID: inserted[0].ID, - Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("edited original")}, + Content: []codersdk.ChatMessagePart{ + reference, + codersdk.ChatMessageText("edited original"), + upload, + }, }) require.NoError(t, err) parts, err := chatprompt.ParseContent(result.Message) require.NoError(t, err) require.Equal(t, []codersdk.ChatMessagePart{ + reference, codersdk.ChatMessageText("edited override"), + upload, {Type: codersdk.ChatMessagePartTypeHookContext, Text: "edit context"}, {Type: codersdk.ChatMessagePartTypeHookNotice, Text: "edit notice"}, }, parts) diff --git a/docs/admin/setup/chat-lifecycle-hooks.md b/docs/admin/setup/chat-lifecycle-hooks.md index 1487cf7f56f..a133b846edc 100644 --- a/docs/admin/setup/chat-lifecycle-hooks.md +++ b/docs/admin/setup/chat-lifecycle-hooks.md @@ -121,6 +121,8 @@ Permission rules depend on the event: - For `user_prompt_submit`, `allow` requires `input_override` in the exact form `{"prompt":"replacement text"}`. Coder stores and sends the replacement prompt instead of the original prompt. + The override replaces only submitted text, matching the concatenated `prompt` field the consumer receives. + Attachments and file references remain in `parts`, so consumers that must block them should inspect `parts` and return `deny`. - For `pre_tool_use`, `allow` requires `input_override` containing the replacement tool input. Coder persists the replacement with the tool call and executes the tool with it. An override for a built-in tool must not repeat a key or vary the capitalization of a schema property; an ambiguous override fails the dispatch closed because the model can't correct it. From 5d54db6ffcf7b8757ca018e5b50987ae4c9ad095 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:11:19 +0000 Subject: [PATCH 02/16] fix: mark chat hook notices with a typed part The chats timeline rendered every user-visible system row as a "Lifecycle hook" notice, which was only correct because hook notices were the sole producer of such rows. Turn-time notices now carry a hook-notice part, the same representation prompt notices already use, and the timeline keys the policy title on that part so any other system row renders as a plain notice. --- coderd/exp_chats_hooks_test.go | 4 +-- coderd/x/chatd/chathooks/effects.go | 7 ++++- codersdk/chats.go | 6 ++--- .../ConversationTimeline.stories.tsx | 26 ++++++++++++++++++- .../ChatConversation/ConversationTimeline.tsx | 17 +++++++++--- .../ChatConversation/messageHelpers.ts | 4 ++- 6 files changed, 53 insertions(+), 11 deletions(-) diff --git a/coderd/exp_chats_hooks_test.go b/coderd/exp_chats_hooks_test.go index f0a67827e98..222ea23456d 100644 --- a/coderd/exp_chats_hooks_test.go +++ b/coderd/exp_chats_hooks_test.go @@ -343,7 +343,7 @@ func TestChatLifecycleHooksWorkedExample(t *testing.T) { continue } for _, part := range message.Content { - if part.Type == codersdk.ChatMessagePartTypeText && part.Text == "Search result approved by policy." { + if part.Type == codersdk.ChatMessagePartTypeHookNotice && part.Text == "Search result approved by policy." { foundPostToolNotice = true } } @@ -591,7 +591,7 @@ func TestChatHookNoticeMessagesInResponses(t *testing.T) { continue } for _, part := range message.Content { - if part.Type == codersdk.ChatMessagePartTypeText && part.Text == "session notice" { + if part.Type == codersdk.ChatMessagePartTypeHookNotice && part.Text == "session notice" { sessionNoticeFound = true } } diff --git a/coderd/x/chatd/chathooks/effects.go b/coderd/x/chatd/chathooks/effects.go index d2f171f0d2d..2569b87652c 100644 --- a/coderd/x/chatd/chathooks/effects.go +++ b/coderd/x/chatd/chathooks/effects.go @@ -36,7 +36,12 @@ func EventMessages(result *Result, modelConfigID uuid.UUID) ([]chatstate.Message }) } if result.GetUserMessage() != "" { - content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText(result.UserMessage)}) + // Use a typed part so clients can distinguish hook notices from other + // system rows. + content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{ + Type: codersdk.ChatMessagePartTypeHookNotice, + Text: result.UserMessage, + }}) if err != nil { return nil, xerrors.Errorf("marshal hook user message: %w", err) } diff --git a/codersdk/chats.go b/codersdk/chats.go index 7c47bead863..039fe30b170 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -295,9 +295,9 @@ const ( // and stripped from every client-facing conversion; the server rejects // it in client-submitted content. ChatMessagePartTypeHookContext ChatMessagePartType = "hook-context" - // ChatMessagePartTypeHookNotice is a user-facing notice attached to a - // user prompt by a lifecycle hook. It is excluded from model prompt - // assembly; the server rejects it in client-submitted content. + // ChatMessagePartTypeHookNotice is a user-facing lifecycle hook notice, + // either attached to a prompt or in its own row. It is excluded from model + // prompts and rejected in client-submitted content. ChatMessagePartTypeHookNotice ChatMessagePartType = "hook-notice" ) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 2e120d34a57..3048171b001 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -415,7 +415,7 @@ export const LifecycleHookNotice: Story = { role: "system", content: [ { - type: "text", + type: "hook-notice", text: "Your organization requires an approval before deployment.", }, ], @@ -438,6 +438,30 @@ export const LifecycleHookNotice: Story = { }, }; +export const SystemMessageWithoutHookNotice: Story = { + args: { + ...defaultArgs, + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "system", + content: [{ type: "text", text: "Maintenance starts in ten minutes." }], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const notice = canvas.getByRole("note"); + expect( + within(notice).getByText("Maintenance starts in ten minutes."), + ).toBeVisible(); + expect( + within(notice).queryByText("Lifecycle hook"), + ).not.toBeInTheDocument(); + }, +}; + export const LifecycleHookNoticeOnUserMessage: Story = { args: { ...defaultArgs, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 6a84edcc895..e5ddf9763a5 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -631,9 +631,20 @@ const ChatMessageItem = memo<{ // Keep links in dimmed notices out of accessibility navigation. inert={isAfterEditingMessage ? true : undefined} > - - {parsed.markdown} - + {parsed.hookNotices.length > 0 ? ( + parsed.hookNotices.map((notice, index) => ( + + {notice} + + )) + ) : ( + + {parsed.markdown} + + )} ); } diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts index dd401e2a363..9f40f14ede3 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts @@ -68,7 +68,9 @@ const getRenderableContentState = (parsed: ParsedMessageContent) => { const hasRenderableContent = visibleBlocks.length > 0 || visibleTools.length > 0 || - parsed.sources.length > 0; + parsed.sources.length > 0 || + // Keep hook-notice-only rows visible in the timeline. + parsed.hookNotices.length > 0; const hasThinkingOnlyContent = visibleBlocks.length > 0 && visibleBlocks.every((block) => block.type === "thinking"); From 5a729fa10357293245c9caf41ad87b8bd820bece Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:13:05 +0000 Subject: [PATCH 03/16] feat: mark chat tool calls a lifecycle hook rewrote A pre_tool_use input_override replaced the tool input, and the stored call then rendered exactly like one the model authored. The persisted tool-call part now records that policy replaced the input, and the chat row carries a "Modified by policy" badge, so the rewrite is legible without the consumer returning a user_message. The denied-tool story also gained the parsed_commands that real stored calls carry, so it asserts the summarized label users actually see. --- coderd/apidoc/docs.go | 4 + coderd/apidoc/swagger.json | 4 + coderd/exp_chats_hooks_test.go | 13 ++- coderd/x/chatd/generation.go | 13 +-- coderd/x/chatd/message_conversion.go | 19 ++-- coderd/x/chatd/message_conversion_test.go | 34 +++++++ codersdk/chats.go | 4 + docs/admin/setup/chat-lifecycle-hooks.md | 3 +- docs/reference/api/chats.md | 10 +++ docs/reference/api/schemas.md | 15 ++++ site/src/api/typesGenerated.ts | 6 ++ .../ConversationTimeline.stories.tsx | 90 +++++++++++++++++++ .../ChatConversation/ConversationTimeline.tsx | 22 +++-- .../ChatConversation/messageHelpers.ts | 1 - .../ChatConversation/messageParsing.ts | 2 + .../components/ChatConversation/types.ts | 2 + .../tools/AskUserQuestionTool.stories.tsx | 37 ++++++++ .../tools/AskUserQuestionTool.tsx | 8 ++ .../ChatElements/tools/ReadFilesTool.tsx | 18 +++- .../ChatElements/tools/Tool.stories.tsx | 84 ++++++++++++++++- .../components/ChatElements/tools/Tool.tsx | 50 ++++++----- .../ChatElements/tools/ToolCall.tsx | 63 +++++++++++-- 22 files changed, 440 insertions(+), 62 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3ce8e342ab0..b3a7db68e5c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -17938,6 +17938,10 @@ const docTemplate = `{ "file_name": { "type": "string" }, + "hook_rewritten": { + "description": "HookRewritten indicates a lifecycle hook replaced the input the\nmodel proposed for this call. Without it the stored call is\nindistinguishable from one the model authored.", + "type": "boolean" + }, "is_error": { "type": "boolean" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 92eecc3601c..521e06c2c23 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -16158,6 +16158,10 @@ "file_name": { "type": "string" }, + "hook_rewritten": { + "description": "HookRewritten indicates a lifecycle hook replaced the input the\nmodel proposed for this call. Without it the stored call is\nindistinguishable from one the model authored.", + "type": "boolean" + }, "is_error": { "type": "boolean" }, diff --git a/coderd/exp_chats_hooks_test.go b/coderd/exp_chats_hooks_test.go index 222ea23456d..f5f9cca09a8 100644 --- a/coderd/exp_chats_hooks_test.go +++ b/coderd/exp_chats_hooks_test.go @@ -309,17 +309,26 @@ func TestChatLifecycleHooksWorkedExample(t *testing.T) { messages, err := client.GetChatMessages(ctx, chat.ID, nil) require.NoError(t, err) - var allowedCall *codersdk.ChatMessagePart + var allowedCall, deniedCall *codersdk.ChatMessagePart for _, message := range messages.Messages { for i := range message.Content { part := &message.Content[i] - if part.Type == codersdk.ChatMessagePartTypeToolCall && part.ToolCallID == allowedToolCallID { + if part.Type != codersdk.ChatMessagePartTypeToolCall { + continue + } + switch part.ToolCallID { + case allowedToolCallID: allowedCall = part + case deniedToolCallID: + deniedCall = part } } } require.NotNil(t, allowedCall) require.JSONEq(t, `{"query":"public documentation"}`, string(allowedCall.Args)) + require.True(t, allowedCall.HookRewritten) + require.NotNil(t, deniedCall) + require.False(t, deniedCall.HookRewritten) err = client.SubmitToolResults(ctx, chat.ID, codersdk.SubmitToolResultsRequest{ Results: []codersdk.ToolResult{{ diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index 7e87da9a236..8a440187a03 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -748,12 +748,13 @@ func (s *taskStarter) generateAssistant( } outcome.Step.Content = chathooks.ApplyAdmittedToolCalls(outcome.Step.Content, preflight) messages, err := buildCommitStepMessages(buildCommitStepMessagesInput{ - modelConfigID: prepared.ModelConfigID, - modelCallConfig: prepared.ModelConfig, - step: stepDataFromPersisted(outcome.Step), - toolNameToConfigID: prepared.ToolNameToConfigID, - logger: s.opts.Logger, - contentVersion: chatprompt.CurrentContentVersion, + modelConfigID: prepared.ModelConfigID, + modelCallConfig: prepared.ModelConfig, + step: stepDataFromPersisted(outcome.Step), + toolNameToConfigID: prepared.ToolNameToConfigID, + logger: s.opts.Logger, + contentVersion: chatprompt.CurrentContentVersion, + hookRewrittenToolCalls: preflight.Overrides, }) if err != nil { return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) diff --git a/coderd/x/chatd/message_conversion.go b/coderd/x/chatd/message_conversion.go index 82fcc8c2029..123ba8484a3 100644 --- a/coderd/x/chatd/message_conversion.go +++ b/coderd/x/chatd/message_conversion.go @@ -28,12 +28,13 @@ import ( const interruptedToolResultErrorMessage = "tool call was interrupted before it produced a result" type buildCommitStepMessagesInput struct { - modelConfigID uuid.UUID - modelCallConfig codersdk.ChatModelCallConfig - step stepData - toolNameToConfigID map[string]uuid.UUID - logger slog.Logger - contentVersion int16 + modelConfigID uuid.UUID + modelCallConfig codersdk.ChatModelCallConfig + step stepData + toolNameToConfigID map[string]uuid.UUID + logger slog.Logger + contentVersion int16 + hookRewrittenToolCalls map[string]json.RawMessage } type stepMessagesForCommit struct { @@ -51,7 +52,7 @@ func buildCommitStepMessages(input buildCommitStepMessagesInput) (stepMessagesFo } assistantBlocks, toolResults := splitStepContent(input.step.Content) - assistantParts := buildAssistantParts(input.logger, assistantBlocks, toolResults, input.step, input.toolNameToConfigID) + assistantParts := buildAssistantParts(input.logger, assistantBlocks, toolResults, input.step, input.toolNameToConfigID, input.hookRewrittenToolCalls) messages := make([]chatstate.Message, 0, 1+len(toolResults)) if len(assistantParts) > 0 { @@ -112,6 +113,7 @@ func buildAssistantParts( toolResults []fantasy.ToolResultContent, step stepData, toolNameToConfigID map[string]uuid.UUID, + hookRewrittenToolCalls map[string]json.RawMessage, ) []codersdk.ChatMessagePart { parts := make([]codersdk.ChatMessagePart, 0, len(assistantBlocks)+len(toolResults)) reasoningIdx := 0 @@ -125,6 +127,9 @@ func buildAssistantParts( part.CreatedAt = &ts } } + if part.ToolCallID != "" { + _, part.HookRewritten = hookRewrittenToolCalls[part.ToolCallID] + } case codersdk.ChatMessagePartTypeToolResult: if part.ToolCallID != "" && step.ToolResultCreatedAt != nil { if ts, ok := step.ToolResultCreatedAt[part.ToolCallID]; ok { diff --git a/coderd/x/chatd/message_conversion_test.go b/coderd/x/chatd/message_conversion_test.go index 40eb2fa291d..ccf018d759e 100644 --- a/coderd/x/chatd/message_conversion_test.go +++ b/coderd/x/chatd/message_conversion_test.go @@ -832,3 +832,37 @@ func (s *partialConversionLogSink) entriesAtLevelWithMessage(level slog.Level, m } return entries } + +func TestBuildCommitStepMessages_MarksHookRewrittenToolCalls(t *testing.T) { + t.Parallel() + + got, err := buildCommitStepMessages(buildCommitStepMessagesInput{ + modelConfigID: uuid.New(), + contentVersion: chatprompt.CurrentContentVersion, + logger: slog.Make(), + step: stepData{ + Content: []fantasy.Content{ + fantasy.ToolCallContent{ + ToolCallID: "rewritten", + ToolName: "execute", + Input: `{"command":"echo admitted"}`, + }, + fantasy.ToolCallContent{ + ToolCallID: "untouched", + ToolName: "execute", + Input: `{"command":"echo original"}`, + }, + }, + }, + hookRewrittenToolCalls: map[string]json.RawMessage{"rewritten": {}}, + }) + require.NoError(t, err) + require.Len(t, got.Messages, 1) + + parts := parseMessageParts(t, got.Messages[0].Role, got.Messages[0].Content) + require.Len(t, parts, 2) + require.Equal(t, "rewritten", parts[0].ToolCallID) + require.True(t, parts[0].HookRewritten) + require.Equal(t, "untouched", parts[1].ToolCallID) + require.False(t, parts[1].HookRewritten) +} diff --git a/codersdk/chats.go b/codersdk/chats.go index 039fe30b170..9d2617c2a1d 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -380,6 +380,10 @@ type ChatMessagePart struct { // ProviderExecuted indicates the tool call was executed by // the provider (e.g. Anthropic computer use). ProviderExecuted bool `json:"provider_executed,omitempty" variants:"tool-call?,tool-result?"` + // HookRewritten indicates a lifecycle hook replaced the input the + // model proposed for this call. Without it the stored call is + // indistinguishable from one the model authored. + HookRewritten bool `json:"hook_rewritten,omitempty" variants:"tool-call?"` // CreatedAt is the timestamp this part carries. The semantics // depend on the part type: for tool-call and tool-result parts // it is the time the call was emitted or the result was diff --git a/docs/admin/setup/chat-lifecycle-hooks.md b/docs/admin/setup/chat-lifecycle-hooks.md index a133b846edc..e33cea7991e 100644 --- a/docs/admin/setup/chat-lifecycle-hooks.md +++ b/docs/admin/setup/chat-lifecycle-hooks.md @@ -126,7 +126,8 @@ Permission rules depend on the event: - For `pre_tool_use`, `allow` requires `input_override` containing the replacement tool input. Coder persists the replacement with the tool call and executes the tool with it. An override for a built-in tool must not repeat a key or vary the capitalization of a schema property; an ambiguous override fails the dispatch closed because the model can't correct it. - Nothing marks the call as rewritten in the chat, so the model may misattribute the changed behavior; a consumer that rewrites input should also return `user_message` explaining the change. + The stored call is marked as rewritten, and the chat shows a "Modified by policy" badge. + The marker is client-facing, so return `model_context` if the model also needs an explanation of the rewrite. - For either event, `deny` blocks the input and must not include `input_override`. A denied prompt isn't persisted: Coder rejects the submission and surfaces any returned `user_message` in the rejection, ignoring `model_context`. A denied tool call becomes a synthetic error result, and any returned `model_context` reaches the model separately, so the model can choose another action. diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index aa836872bb4..f84bf3d49a3 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -1674,6 +1674,7 @@ Experimental: this endpoint is subject to change. "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -1756,6 +1757,7 @@ Experimental: this endpoint is subject to change. "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -1891,6 +1893,7 @@ Experimental: this endpoint is subject to change. "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -1972,6 +1975,7 @@ Experimental: this endpoint is subject to change. "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -2054,6 +2058,7 @@ Experimental: this endpoint is subject to change. "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -2190,6 +2195,7 @@ Experimental: this endpoint is subject to change. "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -2271,6 +2277,7 @@ Experimental: this endpoint is subject to change. "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -2695,6 +2702,7 @@ Experimental: this endpoint is subject to change. "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -2775,6 +2783,7 @@ Experimental: this endpoint is subject to change. "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -2843,6 +2852,7 @@ Experimental: this endpoint is subject to change. "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index e1d7560090e..f909ca13ff8 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2869,6 +2869,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -2964,6 +2965,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -3020,6 +3022,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `end_line` | integer | false | | | | `file_id` | [uuid.NullUUID](#uuidnulluuid) | false | | | | `file_name` | string | false | | | +| `hook_rewritten` | boolean | false | | Hook rewritten indicates a lifecycle hook replaced the input the model proposed for this call. Without it the stored call is indistinguishable from one the model authored. | | `is_error` | boolean | false | | | | `is_media` | boolean | false | | | | `mcp_server_config_id` | [uuid.NullUUID](#uuidnulluuid) | false | | | @@ -3133,6 +3136,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -3215,6 +3219,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -3444,6 +3449,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -3609,6 +3615,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -3689,6 +3696,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -3757,6 +3765,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -3874,6 +3883,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -4575,6 +4585,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -4656,6 +4667,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -4738,6 +4750,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -7274,6 +7287,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { @@ -7355,6 +7369,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "valid": true }, "file_name": "string", + "hook_rewritten": true, "is_error": true, "is_media": true, "mcp_server_config_id": { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d6f7e516f40..8b1b9bb1f11 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3556,6 +3556,12 @@ export interface ChatToolCallPart { * the provider (e.g. Anthropic computer use). */ readonly provider_executed?: boolean; + /** + * HookRewritten indicates a lifecycle hook replaced the input the + * model proposed for this call. Without it the stored call is + * indistinguishable from one the model authored. + */ + readonly hook_rewritten?: boolean; /** * CreatedAt is the timestamp this part carries. The semantics * depend on the part type: for tool-call and tool-result parts diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 3048171b001..8ae2b7ff464 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -244,6 +244,7 @@ const buildParsedReadFileEntry = ({ content = "", errorMessage, isError = status === "error", + hookRewritten = false, }: { messageId: number; toolId: string; @@ -252,6 +253,7 @@ const buildParsedReadFileEntry = ({ content?: string; errorMessage?: string; isError?: boolean; + hookRewritten?: boolean; }): ParsedMessageEntry => { const args = { path }; const result = @@ -289,6 +291,7 @@ const buildParsedReadFileEntry = ({ result, isError, status, + hookRewritten, }, ], blocks: [{ type: "tool", id: toolId }], @@ -2620,6 +2623,93 @@ export const SequentialReadFilesCollapsed: Story = { }, }; +export const ReadFileRewrittenByHook: Story = { + args: { + ...defaultArgs, + parsedMessages: [ + buildParsedReadFileEntry({ + messageId: 1, + toolId: "read-rewritten-1", + path: "site/src/redacted.ts", + status: "completed", + content: "export const redacted = true;\n", + hookRewritten: true, + }), + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(await canvas.findByText("Modified by policy")).toBeVisible(); + }, +}; + +export const GroupedReadFilesRewrittenByHook: Story = { + args: { + ...defaultArgs, + parsedMessages: [ + buildParsedReadFileEntry({ + messageId: 1, + toolId: "read-grouped-1", + path: "site/src/a.ts", + status: "completed", + content: "export const a = 1;\n", + }), + buildParsedReadFileEntry({ + messageId: 2, + toolId: "read-grouped-2", + path: "site/src/b.ts", + status: "completed", + content: "export const b = 2;\n", + hookRewritten: true, + }), + ], + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + await step("group header shows the aggregate badge", async () => { + expect(await canvas.findByText("Modified by policy")).toBeVisible(); + }); + await step("expanded rows credit only the rewritten file", async () => { + await userEvent.click( + await canvas.findByRole("button", { name: /Read 2 files/ }), + ); + const rewrittenRow = await canvas.findByRole("button", { + name: /Read b\.ts/, + }); + expect( + within(rewrittenRow).getByText("Modified by policy"), + ).toBeVisible(); + const untouchedRow = await canvas.findByRole("button", { + name: /Read a\.ts/, + }); + expect( + within(untouchedRow).queryByText("Modified by policy"), + ).not.toBeInTheDocument(); + expect(canvas.getAllByText("Modified by policy")).toHaveLength(2); + }); + }, +}; + +export const ReadFileNotRewrittenByHook: Story = { + args: { + ...defaultArgs, + parsedMessages: [ + buildParsedReadFileEntry({ + messageId: 1, + toolId: "read-plain-1", + path: "site/src/plain.ts", + status: "completed", + content: "export const plain = true;\n", + }), + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(await canvas.findByText(/plain\.ts/)).toBeVisible(); + expect(canvas.queryByText("Modified by policy")).not.toBeInTheDocument(); + }, +}; + export const SequentialReadFilesEmptyAndErrorStates: Story = { args: { ...defaultArgs, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index e5ddf9763a5..b2cef85030e 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -222,18 +222,21 @@ const ReadFileTimelineBlock = memo<{ }>(({ tools }) => { const [expanded, setExpanded] = useState(false); const [firstTool] = tools; - if (tools.length === 1) { const readFile = getReadFileToolData(firstTool); return ( -
- -
+ // This row renders ReadFileTool directly instead of going through + // Tool, which is what supplies the policy state everywhere else. + +
+ +
+
); } @@ -451,6 +454,7 @@ export const BlockList: FC<{ } modelIntent={tool.modelIntent} parsedCommands={tool.parsedCommands} + hookRewritten={tool.hookRewritten} /> ); } diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts index 9f40f14ede3..710c8ab382f 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts @@ -69,7 +69,6 @@ const getRenderableContentState = (parsed: ParsedMessageContent) => { visibleBlocks.length > 0 || visibleTools.length > 0 || parsed.sources.length > 0 || - // Keep hook-notice-only rows visible in the timeline. parsed.hookNotices.length > 0; const hasThinkingOnlyContent = visibleBlocks.length > 0 && diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts index f597351090a..da90fb396b5 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts @@ -170,6 +170,7 @@ export const mergeTools = ( mcpServerConfigId: call.mcpServerConfigId || result?.mcpServerConfigId, modelIntent, parsedCommands: call.parsedCommands, + hookRewritten: call.hookRewritten, }); } @@ -224,6 +225,7 @@ export const parseMessageContent = ( args: part.args, parsedCommands: part.parsed_commands, mcpServerConfigId: part.mcp_server_config_id, + hookRewritten: part.hook_rewritten, }); parsed.blocks = ensureToolBlock(parsed.blocks, id); break; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/types.ts b/site/src/pages/AgentsPage/components/ChatConversation/types.ts index ad09653cd43..06b1128722c 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/types.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/types.ts @@ -7,6 +7,7 @@ export type ParsedToolCall = { args?: unknown; parsedCommands?: readonly string[][]; mcpServerConfigId?: string; + hookRewritten?: boolean; }; export type ParsedToolResult = { @@ -27,6 +28,7 @@ export type MergedTool = { mcpServerConfigId?: string; modelIntent?: string; parsedCommands?: readonly string[][]; + hookRewritten?: boolean; /** Set when a process_signal killed/terminated this process. */ killedBySignal?: "kill" | "terminate"; }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.stories.tsx index 9e683e4163f..02eca5ebfd3 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.stories.tsx @@ -456,6 +456,43 @@ export const ReadOnlyPreviousCall: Story = { }, }; +export const CompletedRewrittenByHook: Story = { + args: { + status: "completed", + result: JSON.stringify(multipleQuestionsPayload), + isChatCompleted: true, + isLatestAskUserQuestion: false, + hookRewritten: true, + onSendAskUserQuestionResponse: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(await canvas.findByText("Modified by policy")).toBeVisible(); + expect( + canvas.getByText(/How should we structure the database migration/), + ).toBeInTheDocument(); + }, +}; + +export const CompletedNotRewrittenByHook: Story = { + args: { + status: "completed", + result: JSON.stringify(multipleQuestionsPayload), + isChatCompleted: true, + isLatestAskUserQuestion: false, + onSendAskUserQuestionResponse: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect( + await canvas.findByText(/How should we structure the database migration/), + ).toBeInTheDocument(); + expect(canvas.queryByText("Modified by policy")).not.toBeInTheDocument(); + }, +}; + export const ErrorState: Story = { args: { status: "completed", diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.tsx index 514e6cc84a8..06bdfc976c8 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.tsx @@ -693,6 +693,14 @@ export const AskUserQuestionTool: FC = ({ )} + {/* The completed and interactive paths render no ToolCall.Header, so + the badge that the header would carry is placed here instead. */} + {!isRunning && ( +
+ +
+ )} + {isInteractive ? (
{content}
) : ( diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx index cf2cf63990a..69111703d80 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx @@ -10,11 +10,13 @@ type ReadFileItem = { status: MergedTool["status"]; isError: boolean; errorMessage?: string; + hookRewritten: boolean; }; const getReadFileItem = (tool: MergedTool): ReadFileItem => ({ id: tool.id, status: tool.status, + hookRewritten: tool.hookRewritten ?? false, ...getReadFileToolData(tool), }); @@ -46,11 +48,21 @@ export const ReadFilesTool: FC<{ expanded={expanded} onExpandedChange={onExpandedChange} > - + item.hookRewritten)} + > + +
+ {/* The group header shows the aggregate, so each row needs + its own flag to avoid crediting the rewrite to files the + hook left alone. */} {items.map((item) => ( -
+ -
+ ))}
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index a918bcf9fff..db7f79285d8 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -394,6 +394,7 @@ export const ExecuteDeniedByHook: Story = { status: "error", isError: true, args: { command: "cat /etc/secrets" }, + parsedCommands: [["cat", "/etc/secrets"]], result: { error: "This tool usage was blocked by an external policy (the deployment's lifecycle hook); the tool call was not executed. Reason: secret reads are blocked. This is an administrative policy decision, not a tool or workspace failure; retrying the same call will be denied again. Explain the policy block to the user and adjust your approach.", @@ -401,10 +402,9 @@ export const ExecuteDeniedByHook: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText(/Failed to run cat \/etc\/secrets/)).toBeVisible(); - expect( - canvas.queryByText(/Ran cat \/etc\/secrets/), - ).not.toBeInTheDocument(); + // parsed_commands summarizes the label, as it does for stored calls. + expect(canvas.getByText(/Failed to run cat/)).toBeVisible(); + expect(canvas.queryByText(/Ran cat/)).not.toBeInTheDocument(); await expect( canvas.getByRole("img", { name: /blocked by an external policy/, @@ -414,6 +414,82 @@ export const ExecuteDeniedByHook: Story = { }, }; +export const ExecuteRewrittenByHook: Story = { + args: { + name: "execute", + status: "completed", + args: { command: "echo REWRITTEN_BY_HOOK" }, + parsedCommands: [["echo", "REWRITTEN_BY_HOOK"]], + hookRewritten: true, + result: { output: "REWRITTEN_BY_HOOK", exit_code: 0 }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const header = canvas.getByRole("button", { + name: /^(Expand|Collapse) command, modified by policy$/, + }); + expect(within(header).getByText(/Ran echo/)).toBeVisible(); + expect(within(header).getByText("Modified by policy")).toBeVisible(); + }, +}; + +export const ExecuteNotRewrittenByHook: Story = { + args: { + name: "execute", + status: "completed", + args: { command: "echo original" }, + parsedCommands: [["echo", "original"]], + result: { output: "original", exit_code: 0 }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText(/Ran echo/)).toBeVisible(); + expect(canvas.queryByText("Modified by policy")).not.toBeInTheDocument(); + }, +}; + +export const WriteFileRewrittenByHook: Story = { + args: { + name: "write_file", + status: "completed", + codeDiffDisplayMode: "auto", + args: { + path: "src/utils/helpers.ts", + content: "export const helper = true;\n", + }, + hookRewritten: true, + result: { success: true }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Modified by policy")).toBeVisible(); + }, +}; + +export const SubagentRewrittenByHook: Story = { + args: { + name: "spawn_agent", + status: "completed", + args: { + title: "Workspace diagnostics", + prompt: "Collect logs and summarize why startup failed.", + }, + hookRewritten: true, + result: { + chat_id: "child-chat-id", + title: "Workspace diagnostics", + status: "completed", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const header = canvas.getByRole("button", { + name: /Spawned Workspace diagnostics/, + }); + expect(within(header).getByText("Modified by policy")).toBeVisible(); + }, +}; + export const ExecuteBackgrounded: Story = { args: { name: "execute", diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index 5934b2524f6..ecd9c00253b 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -88,6 +88,7 @@ interface ToolProps extends Omit, "children"> { modelIntent?: string; /** Parsed command tuples ([program] or [program, arg]) for execute tool calls. */ parsedCommands?: readonly string[][]; + hookRewritten?: boolean; shellToolDisplayMode?: TypesGen.AgentDisplayMode; codeDiffDisplayMode?: TypesGen.AgentDisplayMode; } @@ -1063,6 +1064,7 @@ export const Tool = memo( previousResponseText, modelIntent, parsedCommands, + hookRewritten = false, shellToolDisplayMode, codeDiffDisplayMode, ref, @@ -1088,29 +1090,31 @@ export const Tool = memo( )} {...props} > - + + + ); }, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx index d9023d6f87a..508517d39df 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx @@ -1,4 +1,9 @@ -import { ChevronDownIcon, LoaderIcon, TriangleAlertIcon } from "lucide-react"; +import { + ChevronDownIcon, + LoaderIcon, + ShieldIcon, + TriangleAlertIcon, +} from "lucide-react"; import { type ComponentPropsWithoutRef, createContext, @@ -44,6 +49,37 @@ type ToolCallContextValue = { const ToolCallContext = createContext(null); +// Keep policy state in context so HeaderButton can render the badge without +// threading another prop through each renderer. +const ToolPolicyContext = createContext<{ hookRewritten: boolean }>({ + hookRewritten: false, +}); + +const PolicyProvider: FC<{ hookRewritten: boolean; children: ReactNode }> = ({ + hookRewritten, + children, +}) => ( + + {children} + +); + +const PolicyBadge: FC = () => { + const { hookRewritten } = useContext(ToolPolicyContext); + if (!hookRewritten) { + return null; + } + // No tooltip: the badge sits inside the header button, and a tooltip + // trigger there is either unfocusable or nests interactive content in a + // button. The visible text and the button's accessible name carry it. + return ( + + + Modified by policy + + ); +}; + const useToolCallContext = () => { const context = useContext(ToolCallContext); if (!context) { @@ -173,9 +209,21 @@ const HeaderButton: FC = ({ alwaysButton = false, }) => { const { ariaLabel, collapsible, expanded, onToggle } = useToolCallContext(); + const { hookRewritten } = useContext(ToolPolicyContext); + const resolvedAriaLabel = + typeof ariaLabel === "function" ? ariaLabel(expanded) : ariaLabel; + // An aria-label replaces the badge's visible text in the button's + // accessible name, so the policy state has to be restated here. + const buttonAriaLabel = + resolvedAriaLabel && hookRewritten + ? `${resolvedAriaLabel}, modified by policy` + : resolvedAriaLabel; if (!collapsible && !alwaysButton) { return ( - {children} + + {children} + + ); } @@ -191,12 +239,11 @@ const HeaderButton: FC = ({ ); @@ -305,7 +352,9 @@ const Chevron: FC<{ className?: string }> = ({ className }) => { return ( = ({ children }) => { export const ToolCall = { Root, + PolicyProvider, + PolicyBadge, HeaderRow, HeaderButton, LeadingIcon, From 7e5e53110d35319b667e57286b931059eb24f478 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:41:55 +0000 Subject: [PATCH 04/16] chore: remove unnecessary chat hook comments --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- coderd/x/chatd/chathooks/effects.go | 4 ---- codersdk/chats.go | 4 +--- docs/reference/api/schemas.md | 2 +- site/src/api/typesGenerated.ts | 4 +--- .../components/ChatConversation/ConversationTimeline.tsx | 2 -- .../ChatElements/tools/AskUserQuestionTool.tsx | 3 --- .../components/ChatElements/tools/ReadFilesTool.tsx | 3 --- .../components/ChatElements/tools/Tool.stories.tsx | 1 - .../components/ChatElements/tools/ToolCall.tsx | 9 --------- 11 files changed, 5 insertions(+), 31 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b3a7db68e5c..486935a48fb 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -17939,7 +17939,7 @@ const docTemplate = `{ "type": "string" }, "hook_rewritten": { - "description": "HookRewritten indicates a lifecycle hook replaced the input the\nmodel proposed for this call. Without it the stored call is\nindistinguishable from one the model authored.", + "description": "HookRewritten indicates that a lifecycle hook replaced model-proposed tool input.", "type": "boolean" }, "is_error": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 521e06c2c23..1d38140c3da 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -16159,7 +16159,7 @@ "type": "string" }, "hook_rewritten": { - "description": "HookRewritten indicates a lifecycle hook replaced the input the\nmodel proposed for this call. Without it the stored call is\nindistinguishable from one the model authored.", + "description": "HookRewritten indicates that a lifecycle hook replaced model-proposed tool input.", "type": "boolean" }, "is_error": { diff --git a/coderd/x/chatd/chathooks/effects.go b/coderd/x/chatd/chathooks/effects.go index 2569b87652c..7b70b75a2e3 100644 --- a/coderd/x/chatd/chathooks/effects.go +++ b/coderd/x/chatd/chathooks/effects.go @@ -36,8 +36,6 @@ func EventMessages(result *Result, modelConfigID uuid.UUID) ([]chatstate.Message }) } if result.GetUserMessage() != "" { - // Use a typed part so clients can distinguish hook notices from other - // system rows. content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{ Type: codersdk.ChatMessagePartTypeHookNotice, Text: result.UserMessage, @@ -161,8 +159,6 @@ func UserPromptParts(result *Result) []codersdk.ChatMessagePart { return parts } -// The hook's prompt field contains only concatenated text, so an override -// replaces text parts while preserving non-text parts for policy inspection. func applyPromptOverride(parts []codersdk.ChatMessagePart, override string) []codersdk.ChatMessagePart { userParts := make([]codersdk.ChatMessagePart, 0, len(parts)+1) replaced := false diff --git a/codersdk/chats.go b/codersdk/chats.go index 9d2617c2a1d..b44bed51c81 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -380,9 +380,7 @@ type ChatMessagePart struct { // ProviderExecuted indicates the tool call was executed by // the provider (e.g. Anthropic computer use). ProviderExecuted bool `json:"provider_executed,omitempty" variants:"tool-call?,tool-result?"` - // HookRewritten indicates a lifecycle hook replaced the input the - // model proposed for this call. Without it the stored call is - // indistinguishable from one the model authored. + // HookRewritten indicates that a lifecycle hook replaced model-proposed tool input. HookRewritten bool `json:"hook_rewritten,omitempty" variants:"tool-call?"` // CreatedAt is the timestamp this part carries. The semantics // depend on the part type: for tool-call and tool-result parts diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index f909ca13ff8..9de2d6a3bee 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -3022,7 +3022,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `end_line` | integer | false | | | | `file_id` | [uuid.NullUUID](#uuidnulluuid) | false | | | | `file_name` | string | false | | | -| `hook_rewritten` | boolean | false | | Hook rewritten indicates a lifecycle hook replaced the input the model proposed for this call. Without it the stored call is indistinguishable from one the model authored. | +| `hook_rewritten` | boolean | false | | Hook rewritten indicates that a lifecycle hook replaced model-proposed tool input. | | `is_error` | boolean | false | | | | `is_media` | boolean | false | | | | `mcp_server_config_id` | [uuid.NullUUID](#uuidnulluuid) | false | | | diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8b1b9bb1f11..cbae3b6d914 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3557,9 +3557,7 @@ export interface ChatToolCallPart { */ readonly provider_executed?: boolean; /** - * HookRewritten indicates a lifecycle hook replaced the input the - * model proposed for this call. Without it the stored call is - * indistinguishable from one the model authored. + * HookRewritten indicates that a lifecycle hook replaced model-proposed tool input. */ readonly hook_rewritten?: boolean; /** diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index b2cef85030e..4f1be71f1f2 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -225,8 +225,6 @@ const ReadFileTimelineBlock = memo<{ if (tools.length === 1) { const readFile = getReadFileToolData(firstTool); return ( - // This row renders ReadFileTool directly instead of going through - // Tool, which is what supplies the policy state everywhere else.
= ({ /> )} - - {/* The completed and interactive paths render no ToolCall.Header, so - the badge that the header would carry is placed here instead. */} {!isRunning && (
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx index 69111703d80..8ec9497a4f0 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx @@ -55,9 +55,6 @@ export const ReadFilesTool: FC<{
- {/* The group header shows the aggregate, so each row needs - its own flag to avoid crediting the rewrite to files the - hook left alone. */} {items.map((item) => ( { const canvas = within(canvasElement); - // parsed_commands summarizes the label, as it does for stored calls. expect(canvas.getByText(/Failed to run cat/)).toBeVisible(); expect(canvas.queryByText(/Ran cat/)).not.toBeInTheDocument(); await expect( diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx index 508517d39df..99dea87e1ae 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx @@ -49,8 +49,6 @@ type ToolCallContextValue = { const ToolCallContext = createContext(null); -// Keep policy state in context so HeaderButton can render the badge without -// threading another prop through each renderer. const ToolPolicyContext = createContext<{ hookRewritten: boolean }>({ hookRewritten: false, }); @@ -69,9 +67,6 @@ const PolicyBadge: FC = () => { if (!hookRewritten) { return null; } - // No tooltip: the badge sits inside the header button, and a tooltip - // trigger there is either unfocusable or nests interactive content in a - // button. The visible text and the button's accessible name carry it. return ( @@ -212,8 +207,6 @@ const HeaderButton: FC = ({ const { hookRewritten } = useContext(ToolPolicyContext); const resolvedAriaLabel = typeof ariaLabel === "function" ? ariaLabel(expanded) : ariaLabel; - // An aria-label replaces the badge's visible text in the button's - // accessible name, so the policy state has to be restated here. const buttonAriaLabel = resolvedAriaLabel && hookRewritten ? `${resolvedAriaLabel}, modified by policy` @@ -352,8 +345,6 @@ const Chevron: FC<{ className?: string }> = ({ className }) => { return ( Date: Thu, 30 Jul 2026 15:07:43 +0000 Subject: [PATCH 05/16] test(site/src): assert the policy badge covers every tool renderer The "Modified by policy" badge reaches tools implicitly through the shared ToolCall header, so a renderer that builds its own header rows drops it without a type or runtime error. Two such renderers shipped without the badge and were caught only in review. Assert the badge over the whole renderer registry instead of a hand-picked subset, and fail when a registry entry has no fixture. Also cover the non-collapsible header branch, which no rewritten story exercised. --- .../ChatElements/tools/Tool.stories.tsx | 122 +++++++++++++++++- .../components/ChatElements/tools/Tool.tsx | 4 + 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index f1dc3e1940d..b51d2a51f7a 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -6,7 +6,7 @@ import { MockChatModelConfig } from "#/testHelpers/chatModels"; import { ChatWorkspaceContext } from "../../../context/ChatWorkspaceContext"; import { BlockList } from "../../ChatConversation/ConversationTimeline"; import { DesktopPanelContext } from "./DesktopPanelContext"; -import { Tool } from "./Tool"; +import { Tool, toolRendererNames } from "./Tool"; const executeCommand = "git fetch origin"; const executeIntentCommand = "npm test"; @@ -127,6 +127,13 @@ const allToolShowcaseItems: ToolShowcaseItem[] = [ count: 1, }, }, + { + name: "list_agents", + result: { + agents: [{ id: "agent-1", title: "Workspace diagnostics" }], + total: 1, + }, + }, { name: "read_template", args: { template_id: "template-1" }, @@ -489,6 +496,41 @@ export const SubagentRewrittenByHook: Story = { }, }; +export const NonCollapsibleRewrittenByHook: Story = { + args: { + name: "read_template", + status: "completed", + args: { template_id: "template-1" }, + hookRewritten: true, + result: { + template: { name: "go-template", display_name: "Go Development" }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.queryByRole("button", { name: /Read template/ }), + ).not.toBeInTheDocument(); + expect(canvas.getByText("Modified by policy")).toBeVisible(); + }, +}; + +export const NonCollapsibleNotRewrittenByHook: Story = { + args: { + name: "read_template", + status: "completed", + args: { template_id: "template-1" }, + result: { + template: { name: "go-template", display_name: "Go Development" }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Read template Go Development")).toBeVisible(); + expect(canvas.queryByText("Modified by policy")).not.toBeInTheDocument(); + }, +}; + export const ExecuteBackgrounded: Story = { args: { name: "execute", @@ -3234,3 +3276,81 @@ export const AllToolIconsTranscript: Story = { ], }, }; + +// The badge reaches tools implicitly through the shared header, so a renderer +// that builds its own header rows drops it with no type or runtime error. +// Asserting over the whole registry also fails when a new tool ships uncovered. +export const PolicyBadgeCoversEveryRenderer: Story = { + render: () => ( + + +
+ {allToolShowcaseItems.map((tool, index) => ( +
+ +
+ ))} +
+
+
+ ), + parameters: { + queries: [ + { + key: ["workspace", "test-workspace-id"], + data: { + id: "test-workspace-id", + latest_build: { id: "test-build-id", status: "running" }, + }, + }, + { + key: [ + "workspaceBuilds", + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "logs", + ], + data: [], + }, + ], + }, + play: async ({ canvasElement }) => { + const covered = new Set(allToolShowcaseItems.map((tool) => tool.name)); + expect( + toolRendererNames.filter((name) => !covered.has(name)), + ).toStrictEqual([]); + + const rendered: string[] = []; + const missingBadge: string[] = []; + for (const toolCase of canvasElement.querySelectorAll( + "[data-policy-case]", + )) { + const name = toolCase.getAttribute("data-policy-case") ?? ""; + if (toolCase.textContent?.trim() === "") { + continue; + } + rendered.push(name); + if (!within(toolCase as HTMLElement).queryByText("Modified by policy")) { + missingBadge.push(name); + } + } + + expect(missingBadge).toStrictEqual([]); + expect(rendered.length).toBeGreaterThanOrEqual(toolRendererNames.length); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index ecd9c00253b..a44ba3d1d81 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -1038,6 +1038,10 @@ const toolRenderers: Record> = { computer: ComputerRenderer, }; +// Exported so tests can assert cross-cutting header affordances across every +// dispatch target instead of a hand-picked subset. +export const toolRendererNames: readonly string[] = Object.keys(toolRenderers); + // --------------------------------------------------------------------------- // Public Tool component with a single wrapper div and map dispatch. // --------------------------------------------------------------------------- From d0778382bfabf29193de08cfefa7f31f1f2ef8b8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:07:47 +0000 Subject: [PATCH 06/16] refactor(site/src): own the tool policy flag in ToolCall.Root Tool headers now read `hookRewritten` from the context `ToolCall.Root` already creates, instead of a separate exported `PolicyProvider` that every caller had to remember to wrap. Renderers forward the flag to their root the same way they already forward `status` and `isError`. The two rows that render a bare `ToolCall.PolicyBadge` (the execute auth-required card and the answered ask_user_question row) now sit inside a root, so a badge rendered outside one throws instead of silently rendering nothing. --- .../ChatConversation/ConversationTimeline.tsx | 19 ++-- .../ChatElements/tools/AdvisorTool.tsx | 3 + .../tools/AskUserQuestionTool.tsx | 22 +++-- .../ChatElements/tools/ChatSummarizedTool.tsx | 4 +- .../ChatElements/tools/ComputerTool.tsx | 12 ++- .../tools/CreateWorkspaceTool.tsx | 3 + .../ChatElements/tools/EditFilesTool.tsx | 12 ++- .../ChatElements/tools/ExecuteTool.tsx | 3 + .../ChatElements/tools/ListAgentsTool.tsx | 4 +- .../ChatElements/tools/ListTemplatesTool.tsx | 4 +- .../ChatElements/tools/ProcessOutputTool.tsx | 3 + .../ChatElements/tools/ProposePlanTool.tsx | 3 + .../ChatElements/tools/ReadFileTool.tsx | 3 + .../ChatElements/tools/ReadFilesTool.tsx | 48 +++++----- .../ChatElements/tools/ReadSkillTool.tsx | 4 +- .../ChatElements/tools/ReadTemplateTool.tsx | 4 +- .../ChatElements/tools/StartWorkspaceTool.tsx | 3 + .../ChatElements/tools/SubagentTool.tsx | 3 + .../components/ChatElements/tools/Tool.tsx | 88 +++++++++++++------ .../ChatElements/tools/ToolCall.tsx | 45 ++++------ .../ChatElements/tools/WriteFileTool.tsx | 12 ++- 21 files changed, 197 insertions(+), 105 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 4f1be71f1f2..71f6c4820a0 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -225,16 +225,15 @@ const ReadFileTimelineBlock = memo<{ if (tools.length === 1) { const readFile = getReadFileToolData(firstTool); return ( - -
- -
-
+
+ +
); } diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx index 12cf7200c66..bacc342ddd7 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx @@ -12,6 +12,7 @@ export type AdvisorToolResultType = "advice" | "limit_reached" | "error"; type AdvisorToolProps = { question: string; status: ToolStatus; + hookRewritten?: boolean; isError: boolean; resultType?: AdvisorToolResultType; advice?: string; @@ -30,6 +31,7 @@ const EMPTY_ADVICE_MESSAGE = "Advisor returned no guidance."; export const AdvisorTool: React.FC = ({ question, status, + hookRewritten, isError, resultType, advice, @@ -55,6 +57,7 @@ export const AdvisorTool: React.FC = ({ return ( Promise | void; + hookRewritten?: boolean; }; const OTHER_OPTION_VALUE = "other"; @@ -377,6 +378,7 @@ export const AskUserQuestionTool: FC = ({ isLatestAskUserQuestion = false, previousResponseText, onSubmitAnswer, + hookRewritten, }) => { const idPrefix = useId(); const filteredQuestions = questions.map(filterQuestionOptions); @@ -542,6 +544,7 @@ export const AskUserQuestionTool: FC = ({ isError errorMessage={errorMessage || "Failed to ask questions"} hasContent={false} + hookRewritten={hookRewritten} > = ({ hasContent={false} role="status" aria-live="polite" + hookRewritten={hookRewritten} > = ({ ); return ( -
+ {isRunning && ( - +
- +
)} {!isRunning && (
@@ -703,6 +707,6 @@ export const AskUserQuestionTool: FC = ({ ) : ( content )} -
+
); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx index bca2c3a6eba..c653164225c 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx @@ -13,16 +13,18 @@ import type { ToolStatus } from "./utils"; export const ChatSummarizedTool: React.FC<{ summary: string; status: ToolStatus; + hookRewritten?: boolean; isError: boolean; errorMessage?: string; source?: string; -}> = ({ summary, status, isError, errorMessage, source }) => { +}> = ({ summary, status, hookRewritten, isError, errorMessage, source }) => { const hasSummary = summary.trim().length > 0; const isRunning = status === "running"; const isManual = source === "manual"; return ( = ({ imageData, mimeType, text, status, isError, errorMessage }) => { +}> = ({ + imageData, + mimeType, + text, + status, + hookRewritten, + isError, + errorMessage, +}) => { const [showLightbox, setShowLightbox] = useState(false); const isRunning = status === "running"; const hasImage = imageData.length > 0; @@ -28,6 +37,7 @@ export const ComputerTool: React.FC<{ return ( = ({ files, diffs, status, isError, errorMessage, codeDiffDisplayMode }) => { +}> = ({ + files, + diffs, + status, + hookRewritten, + isError, + errorMessage, + codeDiffDisplayMode, +}) => { const theme = useTheme(); const isDark = theme.palette.mode === "dark"; const isRunning = status === "running"; @@ -56,6 +65,7 @@ export const EditFilesTool: React.FC<{ return ( = ({ @@ -49,6 +50,7 @@ export const ExecuteTool: React.FC = ({ modelIntent, parsedCommands, shellToolDisplayMode, + hookRewritten, }) => { const hasTranscriptBlocks = transcriptBlocks.length > 0; const autoDisplayState: AgentDisplayState = @@ -78,6 +80,7 @@ export const ExecuteTool: React.FC = ({ key={`${shellToolDisplayMode ?? "auto"}:${autoDisplayState}`} className="group/exec grid w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 rounded-md bg-surface-primary font-sans font-normal text-xs leading-5" status={status} + hookRewritten={hookRewritten} isError={isError} errorMessage={errorText || "Command failed"} hasContent diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ListAgentsTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ListAgentsTool.tsx index 05ae1e431c2..9d2d51e5376 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ListAgentsTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ListAgentsTool.tsx @@ -14,9 +14,10 @@ export const ListAgentsTool: React.FC<{ agents: unknown[]; total: number; status: ToolStatus; + hookRewritten?: boolean; isError: boolean; errorMessage?: string; -}> = ({ agents, total, status, isError, errorMessage }) => { +}> = ({ agents, total, status, hookRewritten, isError, errorMessage }) => { const location = useLocation(); const hasContent = agents.length > 0; const isRunning = status === "running"; @@ -29,6 +30,7 @@ export const ListAgentsTool: React.FC<{ return ( = ({ templates, count, status, isError, errorMessage }) => { +}> = ({ templates, count, status, hookRewritten, isError, errorMessage }) => { const hasContent = templates.length > 0; const isRunning = status === "running"; @@ -27,6 +28,7 @@ export const ListTemplatesTool: React.FC<{ return ( = ({ isError, errorMessage, killedBySignal, + hookRewritten, defaultView, outputInitiallyFullyExpanded, }) => { @@ -86,6 +88,7 @@ const ProcessOutputToolInner: React.FC = ({ status={isRunning ? "running" : isError ? "error" : "completed"} isError={isError} errorMessage={errorMessage || "Failed to read process output"} + hookRewritten={hookRewritten} hasContent={hasOutput} defaultView={defaultView} ariaLabel={(expanded) => diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.tsx index c08da0db5cb..0ed30bd91a5 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.tsx @@ -20,6 +20,7 @@ export const ProposePlanTool: React.FC<{ fileID?: string; path: string; status: ToolStatus; + hookRewritten?: boolean; isError: boolean; errorMessage?: string; onImplementPlan?: () => Promise | void; @@ -28,6 +29,7 @@ export const ProposePlanTool: React.FC<{ fileID, path, status, + hookRewritten, isError, errorMessage, onImplementPlan, @@ -76,6 +78,7 @@ export const ProposePlanTool: React.FC<{ return (
item.hookRewritten)} expanded={expanded} onExpandedChange={onExpandedChange} > - item.hookRewritten)} - > - - +
{items.map((item) => ( - - { - setExpandedFileIDs((previous) => { - const next = new Set(previous); - if (nextExpanded) { - next.add(item.id); - } else { - next.delete(item.id); - } - return next; - }); - }} - /> - + path={item.path} + content={item.content} + status={item.status} + isError={item.isError} + errorMessage={item.errorMessage} + expanded={expandedFileIDs.has(item.id)} + onExpandedChange={(nextExpanded) => { + setExpandedFileIDs((previous) => { + const next = new Set(previous); + if (nextExpanded) { + next.add(item.id); + } else { + next.delete(item.id); + } + return next; + }); + }} + /> ))}
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadSkillTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadSkillTool.tsx index baf32f1738f..c8d2aa4e713 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadSkillTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadSkillTool.tsx @@ -8,14 +8,16 @@ export const ReadSkillTool: React.FC<{ label: string; body: string; status: ToolStatus; + hookRewritten?: boolean; isError: boolean; errorMessage?: string; -}> = ({ label, body, status, isError, errorMessage }) => { +}> = ({ label, body, status, hookRewritten, isError, errorMessage }) => { const hasContent = body.length > 0; const isRunning = status === "running"; return ( = ({ templateName, status, isError, errorMessage }) => { +}> = ({ templateName, status, hookRewritten, isError, errorMessage }) => { const isRunning = status === "running"; const label = isRunning @@ -22,6 +23,7 @@ export const ReadTemplateTool: React.FC<{ return ( = ({ status, + hookRewritten, buildId, workspaceName, isError, @@ -38,6 +40,7 @@ export const StartWorkspaceTool: FC = ({ return ( = ({ descriptor, title, @@ -188,6 +189,7 @@ export const SubagentTool: React.FC<{ showDesktopPreview, recordingFileId, thumbnailFileId, + hookRewritten, }) => { const location = useLocation(); const [expanded, setExpanded] = useState(false); @@ -215,6 +217,7 @@ export const SubagentTool: React.FC<{ = ({ status, + hookRewritten, args, result, isError, @@ -228,6 +230,7 @@ const ExecuteRenderer: FC = ({ command={data.command} transcriptBlocks={data.transcriptBlocks} status={status} + hookRewritten={hookRewritten} isError={isError} errorText={data.errorText} durationMs={data.durationMs} @@ -242,6 +245,7 @@ const ExecuteRenderer: FC = ({ const ProcessOutputRenderer: FC = ({ status, + hookRewritten, result, isError, killedBySignal, @@ -263,12 +267,14 @@ const ProcessOutputRenderer: FC = ({ errorMessage={errorMessage || undefined} killedBySignal={killedBySignal} shellToolDisplayMode={shellToolDisplayMode} + hookRewritten={hookRewritten} /> ); }; const ReadFileRenderer: FC = ({ status, + hookRewritten, args, result, isError, @@ -276,11 +282,13 @@ const ReadFileRenderer: FC = ({ ); const ReadSkillRenderer: FC = ({ status, + hookRewritten, args, result, isError, @@ -295,6 +303,7 @@ const ReadSkillRenderer: FC = ({ label={skillName ? `skill ${skillName}` : "skill"} body={body} status={status} + hookRewritten={hookRewritten} isError={isError} errorMessage={rec ? asString(rec.error || rec.message) : undefined} /> @@ -303,6 +312,7 @@ const ReadSkillRenderer: FC = ({ const ReadSkillFileRenderer: FC = ({ status, + hookRewritten, args, result, isError, @@ -322,6 +332,7 @@ const ReadSkillFileRenderer: FC = ({ label={label} body={content} status={status} + hookRewritten={hookRewritten} isError={isError} errorMessage={rec ? asString(rec.error || rec.message) : undefined} /> @@ -330,6 +341,7 @@ const ReadSkillFileRenderer: FC = ({ const WriteFileRenderer: FC = ({ status, + hookRewritten, args, result, isError, @@ -345,6 +357,7 @@ const WriteFileRenderer: FC = ({ path={path || "file"} diff={writeFileDiff} status={status} + hookRewritten={hookRewritten} isError={isError} errorMessage={rec ? asString(rec.error || rec.message) : undefined} codeDiffDisplayMode={codeDiffDisplayMode} @@ -354,6 +367,7 @@ const WriteFileRenderer: FC = ({ const EditFilesRenderer: FC = ({ status, + hookRewritten, args, result, isError, @@ -378,6 +392,7 @@ const EditFilesRenderer: FC = ({ files={editFiles} diffs={editDiffs} status={status} + hookRewritten={hookRewritten} isError={isError} errorMessage={rec ? asString(rec.error || rec.message) : undefined} codeDiffDisplayMode={codeDiffDisplayMode} @@ -389,6 +404,7 @@ const EditFilesRenderer: FC = ({ // with workspace metadata. const CreateWorkspaceRenderer: FC = ({ status, + hookRewritten, result, isError, }) => { @@ -405,6 +421,7 @@ const CreateWorkspaceRenderer: FC = ({ workspaceName={wsName} resultJson={resultJson} status={status} + hookRewritten={hookRewritten} isError={isError || hasErrorInResult} errorMessage={rec ? asString(rec.error || rec.reason) : undefined} buildId={buildId} @@ -417,6 +434,7 @@ const CreateWorkspaceRenderer: FC = ({ const SubagentRenderer: FC = ({ name, status, + hookRewritten, args, result, isError, @@ -519,12 +537,14 @@ const SubagentRenderer: FC = ({ } recordingFileId={recordingFileId || undefined} thumbnailFileId={thumbnailFileId || undefined} + hookRewritten={hookRewritten} /> ); }; const ListTemplatesRenderer: FC = ({ status, + hookRewritten, result, isError, }) => { @@ -539,6 +559,7 @@ const ListTemplatesRenderer: FC = ({ templates={templates} count={count} status={status} + hookRewritten={hookRewritten} isError={isError} errorMessage={rec ? asString(rec.error || rec.message) : undefined} /> @@ -547,6 +568,7 @@ const ListTemplatesRenderer: FC = ({ const ListAgentsRenderer: FC = ({ status, + hookRewritten, result, isError, }) => { @@ -561,6 +583,7 @@ const ListAgentsRenderer: FC = ({ agents={agents} total={total} status={status} + hookRewritten={hookRewritten} isError={isError} errorMessage={ rec @@ -575,6 +598,7 @@ const ListAgentsRenderer: FC = ({ const ReadTemplateRenderer: FC = ({ status, + hookRewritten, result, isError, }) => { @@ -588,6 +612,7 @@ const ReadTemplateRenderer: FC = ({ @@ -596,6 +621,7 @@ const ReadTemplateRenderer: FC = ({ const ChatSummarizedRenderer: FC = ({ status, + hookRewritten, args, result, isError, @@ -615,6 +641,7 @@ const ChatSummarizedRenderer: FC = ({ = ({ const AskUserQuestionRenderer: FC = ({ args, status, + hookRewritten, result, isError, onSendAskUserQuestionResponse, @@ -653,6 +681,7 @@ const AskUserQuestionRenderer: FC = ({ = ({ const ProposePlanRenderer: FC = ({ args, status, + hookRewritten, result, isError, onImplementPlan, @@ -686,6 +716,7 @@ const ProposePlanRenderer: FC = ({ fileID={fileID} path={path} status={status} + hookRewritten={hookRewritten} isError={isError} errorMessage={errorMessage} onImplementPlan={onImplementPlan} @@ -696,6 +727,7 @@ const ProposePlanRenderer: FC = ({ const AdvisorRenderer: FC = ({ args, status, + hookRewritten, result, isError, }) => { @@ -732,6 +764,7 @@ const AdvisorRenderer: FC = ({ = ({ const ComputerRenderer: FC = ({ status, + hookRewritten, result, isError, }) => { @@ -799,6 +833,7 @@ const ComputerRenderer: FC = ({ mimeType={mimeType} text={text} status={status} + hookRewritten={hookRewritten} isError={isError} /> ); @@ -899,6 +934,7 @@ const getGenericToolErrorMessage = ({ const GenericToolRenderer: FC = ({ name, status, + hookRewritten, args, result, isError, @@ -936,6 +972,7 @@ const GenericToolRenderer: FC = ({ return ( = (props) => { const StartWorkspaceRenderer: FC = ({ status, + hookRewritten, result, isError, }) => { @@ -1003,6 +1041,7 @@ const StartWorkspaceRenderer: FC = ({ return ( - - - +
); }, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx index 99dea87e1ae..21bde290344 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx @@ -42,6 +42,7 @@ type ToolCallContextValue = { errorMessage?: string; expanded: boolean; failed: boolean; + hookRewritten: boolean; onToggle: () => void; status: ToolStatus; view: ToolCallView; @@ -49,21 +50,18 @@ type ToolCallContextValue = { const ToolCallContext = createContext(null); -const ToolPolicyContext = createContext<{ hookRewritten: boolean }>({ - hookRewritten: false, -}); - -const PolicyProvider: FC<{ hookRewritten: boolean; children: ReactNode }> = ({ - hookRewritten, - children, -}) => ( - - {children} - -); +const useToolCallContext = () => { + const context = useContext(ToolCallContext); + if (!context) { + throw new Error( + "ToolCall components must be rendered inside ToolCall.Root", + ); + } + return context; +}; const PolicyBadge: FC = () => { - const { hookRewritten } = useContext(ToolPolicyContext); + const { hookRewritten } = useToolCallContext(); if (!hookRewritten) { return null; } @@ -75,16 +73,6 @@ const PolicyBadge: FC = () => { ); }; -const useToolCallContext = () => { - const context = useContext(ToolCallContext); - if (!context) { - throw new Error( - "ToolCall components must be rendered inside ToolCall.Root", - ); - } - return context; -}; - /** * Props for {@link ToolCall.Root}. * @@ -98,6 +86,9 @@ const useToolCallContext = () => { * * Standard `div` attributes are forwarded to the wrapper element so * callers can attach semantics such as live region roles. + * + * `hookRewritten` reaches descendants through context, so a + * {@link ToolCall.PolicyBadge} anywhere under this root picks it up. */ type ToolCallRootProps = Omit, "children"> & { children: ReactNode; @@ -112,6 +103,7 @@ type ToolCallRootProps = Omit, "children"> & { onViewChange?: (view: ToolCallView) => void; ariaLabel?: ToolCallAriaLabel; view?: ToolCallView; + hookRewritten?: boolean; }; /** @@ -135,6 +127,7 @@ const Root: FC = ({ ariaLabel, className, view: viewProp, + hookRewritten = false, ...divProps }) => { const [uncontrolledView, setUncontrolledView] = useState( @@ -170,6 +163,7 @@ const Root: FC = ({ errorMessage, expanded, failed, + hookRewritten, onToggle, status, view, @@ -203,8 +197,8 @@ const HeaderButton: FC = ({ className, alwaysButton = false, }) => { - const { ariaLabel, collapsible, expanded, onToggle } = useToolCallContext(); - const { hookRewritten } = useContext(ToolPolicyContext); + const { ariaLabel, collapsible, expanded, hookRewritten, onToggle } = + useToolCallContext(); const resolvedAriaLabel = typeof ariaLabel === "function" ? ariaLabel(expanded) : ariaLabel; const buttonAriaLabel = @@ -447,7 +441,6 @@ const Content: FC = ({ children }) => { export const ToolCall = { Root, - PolicyProvider, PolicyBadge, HeaderRow, HeaderButton, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx index 4c43eee4935..965c43f60e8 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx @@ -25,10 +25,19 @@ export const WriteFileTool: React.FC<{ path: string; diff: FileDiffMetadata | null; status: ToolStatus; + hookRewritten?: boolean; isError: boolean; errorMessage?: string; codeDiffDisplayMode?: TypesGen.AgentDisplayMode; -}> = ({ path, diff, status, isError, errorMessage, codeDiffDisplayMode }) => { +}> = ({ + path, + diff, + status, + hookRewritten, + isError, + errorMessage, + codeDiffDisplayMode, +}) => { const theme = useTheme(); const isDark = theme.palette.mode === "dark"; const hasDiff = diff !== null; @@ -52,6 +61,7 @@ export const WriteFileTool: React.FC<{ return ( Date: Thu, 30 Jul 2026 16:28:04 +0000 Subject: [PATCH 07/16] refactor(site/src): require every registry name in the badge story The registry-wide story counted rendered cases, so the extra subagent and unknown-tool fixtures could offset a registered renderer that produced nothing and the count assertion still passed. It now records which registry names actually rendered and requires all of them. Folds the negative auth-required badge assertion into the story it duplicated, and makes the internal renderer prop required now that `Tool` normalizes the public one. --- .../components/ChatConversation/ConversationTimeline.tsx | 2 +- .../components/ChatElements/tools/Tool.stories.tsx | 9 +++++---- .../AgentsPage/components/ChatElements/tools/Tool.tsx | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 71f6c4820a0..6596b2c25b9 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -229,7 +229,7 @@ const ReadFileTimelineBlock = memo<{ diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index b51d2a51f7a..f74fd5368a1 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -3279,7 +3279,6 @@ export const AllToolIconsTranscript: Story = { // The badge reaches tools implicitly through the shared header, so a renderer // that builds its own header rows drops it with no type or runtime error. -// Asserting over the whole registry also fails when a new tool ships uncovered. export const PolicyBadgeCoversEveryRenderer: Story = { render: () => ( @@ -3335,7 +3334,7 @@ export const PolicyBadgeCoversEveryRenderer: Story = { toolRendererNames.filter((name) => !covered.has(name)), ).toStrictEqual([]); - const rendered: string[] = []; + const rendered = new Set(); const missingBadge: string[] = []; for (const toolCase of canvasElement.querySelectorAll( "[data-policy-case]", @@ -3344,13 +3343,15 @@ export const PolicyBadgeCoversEveryRenderer: Story = { if (toolCase.textContent?.trim() === "") { continue; } - rendered.push(name); + rendered.add(name); if (!within(toolCase as HTMLElement).queryByText("Modified by policy")) { missingBadge.push(name); } } expect(missingBadge).toStrictEqual([]); - expect(rendered.length).toBeGreaterThanOrEqual(toolRendererNames.length); + expect( + toolRendererNames.filter((name) => !rendered.has(name)), + ).toStrictEqual([]); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index 20dabf775b4..cb87ea1d499 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -117,7 +117,7 @@ type ToolRendererProps = { parsedCommands?: readonly string[][]; shellToolDisplayMode?: TypesGen.AgentDisplayMode; codeDiffDisplayMode?: TypesGen.AgentDisplayMode; - hookRewritten?: boolean; + hookRewritten: boolean; }; // --------------------------------------------------------------------------- @@ -1078,7 +1078,7 @@ const toolRenderers: Record> = { }; // Exported so tests can assert cross-cutting header affordances across every -// dispatch target instead of a hand-picked subset. +// registered renderer instead of a hand-picked subset. export const toolRendererNames: readonly string[] = Object.keys(toolRenderers); // --------------------------------------------------------------------------- From df3bf6306709994e093b2ebf6a9861aea5201cf3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:09:07 +0000 Subject: [PATCH 08/16] test(site/src): query the badge coverage cases by test id FE10 forbids `querySelector` in tests. The registry-wide badge story scanned a private `data-policy-case` attribute and cast each node, so a markup change could silently stop the assertions from finding anything. It now walks the fixture table and looks each case up with `getByTestId`, which is the sanctioned query for a wrapper that has no semantic role. --- .../ChatElements/tools/Tool.stories.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index f74fd5368a1..1362611e2ef 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -3279,6 +3279,9 @@ export const AllToolIconsTranscript: Story = { // The badge reaches tools implicitly through the shared header, so a renderer // that builds its own header rows drops it with no type or runtime error. +const policyCaseTestId = (name: string, index: number) => + `policy-case-${name}-${index}`; + export const PolicyBadgeCoversEveryRenderer: Story = { render: () => ( @@ -3287,7 +3290,10 @@ export const PolicyBadgeCoversEveryRenderer: Story = { >
{allToolShowcaseItems.map((tool, index) => ( -
+
!covered.has(name)), ).toStrictEqual([]); + const canvas = within(canvasElement); const rendered = new Set(); const missingBadge: string[] = []; - for (const toolCase of canvasElement.querySelectorAll( - "[data-policy-case]", - )) { - const name = toolCase.getAttribute("data-policy-case") ?? ""; + allToolShowcaseItems.forEach((tool, index) => { + const toolCase = canvas.getByTestId(policyCaseTestId(tool.name, index)); if (toolCase.textContent?.trim() === "") { - continue; + return; } - rendered.add(name); - if (!within(toolCase as HTMLElement).queryByText("Modified by policy")) { - missingBadge.push(name); + rendered.add(tool.name); + if (!within(toolCase).queryByText("Modified by policy")) { + missingBadge.push(tool.name); } - } + }); expect(missingBadge).toStrictEqual([]); expect( From a2c4a9b5d840f18629cfd55bf9485a0b211a608d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:47:32 +0000 Subject: [PATCH 09/16] fix(coderd/x/chatd): skip rewrite attribution for provider-executed calls Hooks never see provider-executed tool calls, so a provider-executed call that reused an admitted call's ID inherited hook_rewritten and the API attributed it to policy. Duplicate-ID rejection deliberately skips provider-executed calls, so the collision reaches conversion. --- coderd/x/chatd/message_conversion.go | 4 ++- coderd/x/chatd/message_conversion_test.go | 35 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/coderd/x/chatd/message_conversion.go b/coderd/x/chatd/message_conversion.go index 123ba8484a3..d03ae1462fe 100644 --- a/coderd/x/chatd/message_conversion.go +++ b/coderd/x/chatd/message_conversion.go @@ -127,7 +127,9 @@ func buildAssistantParts( part.CreatedAt = &ts } } - if part.ToolCallID != "" { + // Hooks never see provider-executed calls, so such a call must not + // inherit attribution from an ordinary call that reused its ID. + if part.ToolCallID != "" && !part.ProviderExecuted { _, part.HookRewritten = hookRewrittenToolCalls[part.ToolCallID] } case codersdk.ChatMessagePartTypeToolResult: diff --git a/coderd/x/chatd/message_conversion_test.go b/coderd/x/chatd/message_conversion_test.go index ccf018d759e..f49f1fa2138 100644 --- a/coderd/x/chatd/message_conversion_test.go +++ b/coderd/x/chatd/message_conversion_test.go @@ -866,3 +866,38 @@ func TestBuildCommitStepMessages_MarksHookRewrittenToolCalls(t *testing.T) { require.Equal(t, "untouched", parts[1].ToolCallID) require.False(t, parts[1].HookRewritten) } + +func TestBuildCommitStepMessages_SkipsProviderExecutedRewriteAttribution(t *testing.T) { + t.Parallel() + + got, err := buildCommitStepMessages(buildCommitStepMessagesInput{ + modelConfigID: uuid.New(), + contentVersion: chatprompt.CurrentContentVersion, + logger: slog.Make(), + step: stepData{ + Content: []fantasy.Content{ + fantasy.ToolCallContent{ + ToolCallID: "shared", + ToolName: "web_search", + Input: `{"query":"coder"}`, + ProviderExecuted: true, + }, + fantasy.ToolCallContent{ + ToolCallID: "shared", + ToolName: "execute", + Input: `{"command":"echo admitted"}`, + }, + }, + }, + hookRewrittenToolCalls: map[string]json.RawMessage{"shared": {}}, + }) + require.NoError(t, err) + require.Len(t, got.Messages, 1) + + parts := parseMessageParts(t, got.Messages[0].Role, got.Messages[0].Content) + require.Len(t, parts, 2) + require.True(t, parts[0].ProviderExecuted) + require.False(t, parts[0].HookRewritten) + require.False(t, parts[1].ProviderExecuted) + require.True(t, parts[1].HookRewritten) +} From 919cae8f4b6f7586b283fa266e31e0c6218632a7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:48:41 +0000 Subject: [PATCH 10/16] revert(site/src): stop piping the policy flag through every renderer Threading `hookRewritten` from `Tool` into each renderer and on into every `ToolCall.Root` touched 21 files to express one cross-cutting display flag. `ToolCall.PolicyProvider` returns, and the provider now renders the badge itself instead of relying on the shared header. Emitting the badge at the provider closes the gap the prop threading was chasing: a renderer branch that returns early without a `ToolCall.Header`, such as the auth-required execute card or an empty `ask_user_question` payload, keeps the attribution without participating at all. Renderers no longer see the flag, and 15 of them leave the diff entirely. A rewritten call is wrapped in a group labelled by its badge, so a nested row such as one file inside a merged read carries its own attribution. `ReadFileTool` names its header row, which had been relying on the badge sitting inside the button to describe the row. --- .../ConversationTimeline.stories.tsx | 14 ++- .../ChatConversation/ConversationTimeline.tsx | 19 ++-- .../ChatElements/tools/AdvisorTool.tsx | 3 - .../tools/AskUserQuestionTool.stories.tsx | 16 ++++ .../tools/AskUserQuestionTool.tsx | 28 ++---- .../ChatElements/tools/ChatSummarizedTool.tsx | 4 +- .../ChatElements/tools/ComputerTool.tsx | 12 +-- .../tools/CreateWorkspaceTool.tsx | 3 - .../ChatElements/tools/EditFilesTool.tsx | 12 +-- .../ChatElements/tools/ExecuteTool.tsx | 3 - .../ChatElements/tools/ListAgentsTool.tsx | 4 +- .../ChatElements/tools/ListTemplatesTool.tsx | 4 +- .../ChatElements/tools/ProcessOutputTool.tsx | 3 - .../ChatElements/tools/ProposePlanTool.tsx | 3 - .../ChatElements/tools/ReadFileTool.tsx | 4 +- .../ChatElements/tools/ReadFilesTool.tsx | 48 +++++----- .../ChatElements/tools/ReadSkillTool.tsx | 4 +- .../ChatElements/tools/ReadTemplateTool.tsx | 4 +- .../ChatElements/tools/StartWorkspaceTool.tsx | 3 - .../ChatElements/tools/SubagentTool.tsx | 3 - .../ChatElements/tools/Tool.stories.tsx | 37 ++++---- .../components/ChatElements/tools/Tool.tsx | 90 ++++++------------- .../ChatElements/tools/ToolCall.tsx | 70 +++++++++------ .../ChatElements/tools/WriteFileTool.tsx | 12 +-- 24 files changed, 164 insertions(+), 239 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 8ae2b7ff464..2154431ce9e 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -2673,18 +2673,14 @@ export const GroupedReadFilesRewrittenByHook: Story = { await userEvent.click( await canvas.findByRole("button", { name: /Read 2 files/ }), ); - const rewrittenRow = await canvas.findByRole("button", { - name: /Read b\.ts/, - }); expect( - within(rewrittenRow).getByText("Modified by policy"), + await canvas.findByRole("button", { + name: "Read b.ts, modified by policy", + }), ).toBeVisible(); - const untouchedRow = await canvas.findByRole("button", { - name: /Read a\.ts/, - }); expect( - within(untouchedRow).queryByText("Modified by policy"), - ).not.toBeInTheDocument(); + await canvas.findByRole("button", { name: "Read a.ts" }), + ).toBeVisible(); expect(canvas.getAllByText("Modified by policy")).toHaveLength(2); }); }, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 6596b2c25b9..4f1be71f1f2 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -225,15 +225,16 @@ const ReadFileTimelineBlock = memo<{ if (tools.length === 1) { const readFile = getReadFileToolData(firstTool); return ( -
- -
+ +
+ +
+
); } diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx index bacc342ddd7..12cf7200c66 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx @@ -12,7 +12,6 @@ export type AdvisorToolResultType = "advice" | "limit_reached" | "error"; type AdvisorToolProps = { question: string; status: ToolStatus; - hookRewritten?: boolean; isError: boolean; resultType?: AdvisorToolResultType; advice?: string; @@ -31,7 +30,6 @@ const EMPTY_ADVICE_MESSAGE = "Advisor returned no guidance."; export const AdvisorTool: React.FC = ({ question, status, - hookRewritten, isError, resultType, advice, @@ -57,7 +55,6 @@ export const AdvisorTool: React.FC = ({ return ( { + const canvas = within(canvasElement); + + expect(await canvas.findByText("No questions available.")).toBeVisible(); + expect(canvas.getByText("Modified by policy")).toBeVisible(); + }, +}; + export const ErrorState: Story = { args: { status: "completed", diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.tsx index 3f31215f48a..774bb627fcc 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/AskUserQuestionTool.tsx @@ -38,7 +38,6 @@ type AskUserQuestionToolProps = { isLatestAskUserQuestion?: boolean; previousResponseText?: string; onSubmitAnswer?: (message: string) => Promise | void; - hookRewritten?: boolean; }; const OTHER_OPTION_VALUE = "other"; @@ -378,7 +377,6 @@ export const AskUserQuestionTool: FC = ({ isLatestAskUserQuestion = false, previousResponseText, onSubmitAnswer, - hookRewritten, }) => { const idPrefix = useId(); const filteredQuestions = questions.map(filterQuestionOptions); @@ -544,7 +542,6 @@ export const AskUserQuestionTool: FC = ({ isError errorMessage={errorMessage || "Failed to ask questions"} hasContent={false} - hookRewritten={hookRewritten} > = ({ hasContent={false} role="status" aria-live="polite" - hookRewritten={hookRewritten} > = ({ ); return ( - +
{isRunning && ( -
+ -
- )} - {!isRunning && ( -
- -
+ )} - {isInteractive ? (
{content}
) : ( content )} - +
); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx index c653164225c..bca2c3a6eba 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx @@ -13,18 +13,16 @@ import type { ToolStatus } from "./utils"; export const ChatSummarizedTool: React.FC<{ summary: string; status: ToolStatus; - hookRewritten?: boolean; isError: boolean; errorMessage?: string; source?: string; -}> = ({ summary, status, hookRewritten, isError, errorMessage, source }) => { +}> = ({ summary, status, isError, errorMessage, source }) => { const hasSummary = summary.trim().length > 0; const isRunning = status === "running"; const isManual = source === "manual"; return ( = ({ - imageData, - mimeType, - text, - status, - hookRewritten, - isError, - errorMessage, -}) => { +}> = ({ imageData, mimeType, text, status, isError, errorMessage }) => { const [showLightbox, setShowLightbox] = useState(false); const isRunning = status === "running"; const hasImage = imageData.length > 0; @@ -37,7 +28,6 @@ export const ComputerTool: React.FC<{ return ( = ({ - files, - diffs, - status, - hookRewritten, - isError, - errorMessage, - codeDiffDisplayMode, -}) => { +}> = ({ files, diffs, status, isError, errorMessage, codeDiffDisplayMode }) => { const theme = useTheme(); const isDark = theme.palette.mode === "dark"; const isRunning = status === "running"; @@ -65,7 +56,6 @@ export const EditFilesTool: React.FC<{ return ( = ({ @@ -50,7 +49,6 @@ export const ExecuteTool: React.FC = ({ modelIntent, parsedCommands, shellToolDisplayMode, - hookRewritten, }) => { const hasTranscriptBlocks = transcriptBlocks.length > 0; const autoDisplayState: AgentDisplayState = @@ -80,7 +78,6 @@ export const ExecuteTool: React.FC = ({ key={`${shellToolDisplayMode ?? "auto"}:${autoDisplayState}`} className="group/exec grid w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 rounded-md bg-surface-primary font-sans font-normal text-xs leading-5" status={status} - hookRewritten={hookRewritten} isError={isError} errorMessage={errorText || "Command failed"} hasContent diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ListAgentsTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ListAgentsTool.tsx index 9d2d51e5376..05ae1e431c2 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ListAgentsTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ListAgentsTool.tsx @@ -14,10 +14,9 @@ export const ListAgentsTool: React.FC<{ agents: unknown[]; total: number; status: ToolStatus; - hookRewritten?: boolean; isError: boolean; errorMessage?: string; -}> = ({ agents, total, status, hookRewritten, isError, errorMessage }) => { +}> = ({ agents, total, status, isError, errorMessage }) => { const location = useLocation(); const hasContent = agents.length > 0; const isRunning = status === "running"; @@ -30,7 +29,6 @@ export const ListAgentsTool: React.FC<{ return ( = ({ templates, count, status, hookRewritten, isError, errorMessage }) => { +}> = ({ templates, count, status, isError, errorMessage }) => { const hasContent = templates.length > 0; const isRunning = status === "running"; @@ -28,7 +27,6 @@ export const ListTemplatesTool: React.FC<{ return ( = ({ isError, errorMessage, killedBySignal, - hookRewritten, defaultView, outputInitiallyFullyExpanded, }) => { @@ -88,7 +86,6 @@ const ProcessOutputToolInner: React.FC = ({ status={isRunning ? "running" : isError ? "error" : "completed"} isError={isError} errorMessage={errorMessage || "Failed to read process output"} - hookRewritten={hookRewritten} hasContent={hasOutput} defaultView={defaultView} ariaLabel={(expanded) => diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.tsx index 0ed30bd91a5..c08da0db5cb 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.tsx @@ -20,7 +20,6 @@ export const ProposePlanTool: React.FC<{ fileID?: string; path: string; status: ToolStatus; - hookRewritten?: boolean; isError: boolean; errorMessage?: string; onImplementPlan?: () => Promise | void; @@ -29,7 +28,6 @@ export const ProposePlanTool: React.FC<{ fileID, path, status, - hookRewritten, isError, errorMessage, onImplementPlan, @@ -78,7 +76,6 @@ export const ProposePlanTool: React.FC<{ return (
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx index 737e0a1b482..8ec9497a4f0 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx @@ -45,35 +45,41 @@ export const ReadFilesTool: FC<{ isError={isError} errorMessage={errorMessage || "Failed to read one or more files"} hasContent={hasContent} - hookRewritten={items.some((item) => item.hookRewritten)} expanded={expanded} onExpandedChange={onExpandedChange} > - + item.hookRewritten)} + > + +
{items.map((item) => ( - { - setExpandedFileIDs((previous) => { - const next = new Set(previous); - if (nextExpanded) { - next.add(item.id); - } else { - next.delete(item.id); - } - return next; - }); - }} - /> + > + { + setExpandedFileIDs((previous) => { + const next = new Set(previous); + if (nextExpanded) { + next.add(item.id); + } else { + next.delete(item.id); + } + return next; + }); + }} + /> + ))}
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadSkillTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadSkillTool.tsx index c8d2aa4e713..baf32f1738f 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadSkillTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadSkillTool.tsx @@ -8,16 +8,14 @@ export const ReadSkillTool: React.FC<{ label: string; body: string; status: ToolStatus; - hookRewritten?: boolean; isError: boolean; errorMessage?: string; -}> = ({ label, body, status, hookRewritten, isError, errorMessage }) => { +}> = ({ label, body, status, isError, errorMessage }) => { const hasContent = body.length > 0; const isRunning = status === "running"; return ( = ({ templateName, status, hookRewritten, isError, errorMessage }) => { +}> = ({ templateName, status, isError, errorMessage }) => { const isRunning = status === "running"; const label = isRunning @@ -23,7 +22,6 @@ export const ReadTemplateTool: React.FC<{ return ( = ({ status, - hookRewritten, buildId, workspaceName, isError, @@ -40,7 +38,6 @@ export const StartWorkspaceTool: FC = ({ return ( = ({ descriptor, title, @@ -189,7 +188,6 @@ export const SubagentTool: React.FC<{ showDesktopPreview, recordingFileId, thumbnailFileId, - hookRewritten, }) => { const location = useLocation(); const [expanded, setExpanded] = useState(false); @@ -217,7 +215,6 @@ export const SubagentTool: React.FC<{ { const canvas = within(canvasElement); - const header = canvas.getByRole("button", { - name: /Spawned Workspace diagnostics/, - }); - expect(within(header).getByText("Modified by policy")).toBeVisible(); + expect( + canvas.getByRole("button", { name: /Spawned Workspace diagnostics/ }), + ).toBeVisible(); + expect(canvas.getByText("Modified by policy")).toBeVisible(); }, }; @@ -3277,10 +3279,11 @@ export const AllToolIconsTranscript: Story = { }, }; -// The badge reaches tools implicitly through the shared header, so a renderer -// that builds its own header rows drops it with no type or runtime error. -const policyCaseTestId = (name: string, index: number) => - `policy-case-${name}-${index}`; +// The badge reaches tools implicitly through the policy provider, so a +// renderer reachable only through a branch that skips it would drop the +// attribution with no type or runtime error. +const policyCaseLabel = (name: string, index: number) => + `policy case ${name} ${index}`; export const PolicyBadgeCoversEveryRenderer: Story = { render: () => ( @@ -3292,7 +3295,8 @@ export const PolicyBadgeCoversEveryRenderer: Story = { {allToolShowcaseItems.map((tool, index) => (
(); const missingBadge: string[] = []; allToolShowcaseItems.forEach((tool, index) => { - const toolCase = canvas.getByTestId(policyCaseTestId(tool.name, index)); + const toolCase = canvas.getByRole("group", { + name: policyCaseLabel(tool.name, index), + }); if (toolCase.textContent?.trim() === "") { return; } diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index cb87ea1d499..a44ba3d1d81 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -117,7 +117,6 @@ type ToolRendererProps = { parsedCommands?: readonly string[][]; shellToolDisplayMode?: TypesGen.AgentDisplayMode; codeDiffDisplayMode?: TypesGen.AgentDisplayMode; - hookRewritten: boolean; }; // --------------------------------------------------------------------------- @@ -215,7 +214,6 @@ const parseAskUserQuestionResult = ( const ExecuteRenderer: FC = ({ status, - hookRewritten, args, result, isError, @@ -230,7 +228,6 @@ const ExecuteRenderer: FC = ({ command={data.command} transcriptBlocks={data.transcriptBlocks} status={status} - hookRewritten={hookRewritten} isError={isError} errorText={data.errorText} durationMs={data.durationMs} @@ -245,7 +242,6 @@ const ExecuteRenderer: FC = ({ const ProcessOutputRenderer: FC = ({ status, - hookRewritten, result, isError, killedBySignal, @@ -267,14 +263,12 @@ const ProcessOutputRenderer: FC = ({ errorMessage={errorMessage || undefined} killedBySignal={killedBySignal} shellToolDisplayMode={shellToolDisplayMode} - hookRewritten={hookRewritten} /> ); }; const ReadFileRenderer: FC = ({ status, - hookRewritten, args, result, isError, @@ -282,13 +276,11 @@ const ReadFileRenderer: FC = ({ ); const ReadSkillRenderer: FC = ({ status, - hookRewritten, args, result, isError, @@ -303,7 +295,6 @@ const ReadSkillRenderer: FC = ({ label={skillName ? `skill ${skillName}` : "skill"} body={body} status={status} - hookRewritten={hookRewritten} isError={isError} errorMessage={rec ? asString(rec.error || rec.message) : undefined} /> @@ -312,7 +303,6 @@ const ReadSkillRenderer: FC = ({ const ReadSkillFileRenderer: FC = ({ status, - hookRewritten, args, result, isError, @@ -332,7 +322,6 @@ const ReadSkillFileRenderer: FC = ({ label={label} body={content} status={status} - hookRewritten={hookRewritten} isError={isError} errorMessage={rec ? asString(rec.error || rec.message) : undefined} /> @@ -341,7 +330,6 @@ const ReadSkillFileRenderer: FC = ({ const WriteFileRenderer: FC = ({ status, - hookRewritten, args, result, isError, @@ -357,7 +345,6 @@ const WriteFileRenderer: FC = ({ path={path || "file"} diff={writeFileDiff} status={status} - hookRewritten={hookRewritten} isError={isError} errorMessage={rec ? asString(rec.error || rec.message) : undefined} codeDiffDisplayMode={codeDiffDisplayMode} @@ -367,7 +354,6 @@ const WriteFileRenderer: FC = ({ const EditFilesRenderer: FC = ({ status, - hookRewritten, args, result, isError, @@ -392,7 +378,6 @@ const EditFilesRenderer: FC = ({ files={editFiles} diffs={editDiffs} status={status} - hookRewritten={hookRewritten} isError={isError} errorMessage={rec ? asString(rec.error || rec.message) : undefined} codeDiffDisplayMode={codeDiffDisplayMode} @@ -404,7 +389,6 @@ const EditFilesRenderer: FC = ({ // with workspace metadata. const CreateWorkspaceRenderer: FC = ({ status, - hookRewritten, result, isError, }) => { @@ -421,7 +405,6 @@ const CreateWorkspaceRenderer: FC = ({ workspaceName={wsName} resultJson={resultJson} status={status} - hookRewritten={hookRewritten} isError={isError || hasErrorInResult} errorMessage={rec ? asString(rec.error || rec.reason) : undefined} buildId={buildId} @@ -434,7 +417,6 @@ const CreateWorkspaceRenderer: FC = ({ const SubagentRenderer: FC = ({ name, status, - hookRewritten, args, result, isError, @@ -537,14 +519,12 @@ const SubagentRenderer: FC = ({ } recordingFileId={recordingFileId || undefined} thumbnailFileId={thumbnailFileId || undefined} - hookRewritten={hookRewritten} /> ); }; const ListTemplatesRenderer: FC = ({ status, - hookRewritten, result, isError, }) => { @@ -559,7 +539,6 @@ const ListTemplatesRenderer: FC = ({ templates={templates} count={count} status={status} - hookRewritten={hookRewritten} isError={isError} errorMessage={rec ? asString(rec.error || rec.message) : undefined} /> @@ -568,7 +547,6 @@ const ListTemplatesRenderer: FC = ({ const ListAgentsRenderer: FC = ({ status, - hookRewritten, result, isError, }) => { @@ -583,7 +561,6 @@ const ListAgentsRenderer: FC = ({ agents={agents} total={total} status={status} - hookRewritten={hookRewritten} isError={isError} errorMessage={ rec @@ -598,7 +575,6 @@ const ListAgentsRenderer: FC = ({ const ReadTemplateRenderer: FC = ({ status, - hookRewritten, result, isError, }) => { @@ -612,7 +588,6 @@ const ReadTemplateRenderer: FC = ({ @@ -621,7 +596,6 @@ const ReadTemplateRenderer: FC = ({ const ChatSummarizedRenderer: FC = ({ status, - hookRewritten, args, result, isError, @@ -641,7 +615,6 @@ const ChatSummarizedRenderer: FC = ({ = ({ const AskUserQuestionRenderer: FC = ({ args, status, - hookRewritten, result, isError, onSendAskUserQuestionResponse, @@ -681,7 +653,6 @@ const AskUserQuestionRenderer: FC = ({ = ({ const ProposePlanRenderer: FC = ({ args, status, - hookRewritten, result, isError, onImplementPlan, @@ -716,7 +686,6 @@ const ProposePlanRenderer: FC = ({ fileID={fileID} path={path} status={status} - hookRewritten={hookRewritten} isError={isError} errorMessage={errorMessage} onImplementPlan={onImplementPlan} @@ -727,7 +696,6 @@ const ProposePlanRenderer: FC = ({ const AdvisorRenderer: FC = ({ args, status, - hookRewritten, result, isError, }) => { @@ -764,7 +732,6 @@ const AdvisorRenderer: FC = ({ = ({ const ComputerRenderer: FC = ({ status, - hookRewritten, result, isError, }) => { @@ -833,7 +799,6 @@ const ComputerRenderer: FC = ({ mimeType={mimeType} text={text} status={status} - hookRewritten={hookRewritten} isError={isError} /> ); @@ -934,7 +899,6 @@ const getGenericToolErrorMessage = ({ const GenericToolRenderer: FC = ({ name, status, - hookRewritten, args, result, isError, @@ -972,7 +936,6 @@ const GenericToolRenderer: FC = ({ return ( = (props) => { const StartWorkspaceRenderer: FC = ({ status, - hookRewritten, result, isError, }) => { @@ -1041,7 +1003,6 @@ const StartWorkspaceRenderer: FC = ({ return ( > = { }; // Exported so tests can assert cross-cutting header affordances across every -// registered renderer instead of a hand-picked subset. +// dispatch target instead of a hand-picked subset. export const toolRendererNames: readonly string[] = Object.keys(toolRenderers); // --------------------------------------------------------------------------- @@ -1133,30 +1094,31 @@ export const Tool = memo( )} {...props} > - + + +
); }, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx index 21bde290344..98bbc6e6de8 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx @@ -10,6 +10,7 @@ import { type FC, type ReactNode, useContext, + useId, useState, } from "react"; import { @@ -42,7 +43,6 @@ type ToolCallContextValue = { errorMessage?: string; expanded: boolean; failed: boolean; - hookRewritten: boolean; onToggle: () => void; status: ToolStatus; view: ToolCallView; @@ -50,6 +50,43 @@ type ToolCallContextValue = { const ToolCallContext = createContext(null); +const ToolPolicyContext = createContext<{ hookRewritten: boolean }>({ + hookRewritten: false, +}); + +/** + * Renders the badge itself rather than delegating to the shared header, + * so a renderer branch that returns early without a `ToolCall.Header` + * keeps the attribution. Renderers never receive `hookRewritten`. + * + * The group labelled by the badge binds the notice to the card it + * describes, which is what lets a nested row be attributed on its own. + */ +const PolicyProvider: FC<{ hookRewritten: boolean; children: ReactNode }> = ({ + hookRewritten, + children, +}) => { + const badgeId = useId(); + return ( + + {hookRewritten ? ( +
+ + + Modified by policy + + {children} +
+ ) : ( + children + )} +
+ ); +}; + const useToolCallContext = () => { const context = useContext(ToolCallContext); if (!context) { @@ -60,19 +97,6 @@ const useToolCallContext = () => { return context; }; -const PolicyBadge: FC = () => { - const { hookRewritten } = useToolCallContext(); - if (!hookRewritten) { - return null; - } - return ( - - - Modified by policy - - ); -}; - /** * Props for {@link ToolCall.Root}. * @@ -86,9 +110,6 @@ const PolicyBadge: FC = () => { * * Standard `div` attributes are forwarded to the wrapper element so * callers can attach semantics such as live region roles. - * - * `hookRewritten` reaches descendants through context, so a - * {@link ToolCall.PolicyBadge} anywhere under this root picks it up. */ type ToolCallRootProps = Omit, "children"> & { children: ReactNode; @@ -103,7 +124,6 @@ type ToolCallRootProps = Omit, "children"> & { onViewChange?: (view: ToolCallView) => void; ariaLabel?: ToolCallAriaLabel; view?: ToolCallView; - hookRewritten?: boolean; }; /** @@ -127,7 +147,6 @@ const Root: FC = ({ ariaLabel, className, view: viewProp, - hookRewritten = false, ...divProps }) => { const [uncontrolledView, setUncontrolledView] = useState( @@ -163,7 +182,6 @@ const Root: FC = ({ errorMessage, expanded, failed, - hookRewritten, onToggle, status, view, @@ -197,8 +215,8 @@ const HeaderButton: FC = ({ className, alwaysButton = false, }) => { - const { ariaLabel, collapsible, expanded, hookRewritten, onToggle } = - useToolCallContext(); + const { ariaLabel, collapsible, expanded, onToggle } = useToolCallContext(); + const { hookRewritten } = useContext(ToolPolicyContext); const resolvedAriaLabel = typeof ariaLabel === "function" ? ariaLabel(expanded) : ariaLabel; const buttonAriaLabel = @@ -207,10 +225,7 @@ const HeaderButton: FC = ({ : resolvedAriaLabel; if (!collapsible && !alwaysButton) { return ( - - {children} - - + {children} ); } @@ -230,7 +245,6 @@ const HeaderButton: FC = ({ onClick={collapsible ? onToggle : undefined} > {children} - ); @@ -441,7 +455,7 @@ const Content: FC = ({ children }) => { export const ToolCall = { Root, - PolicyBadge, + PolicyProvider, HeaderRow, HeaderButton, LeadingIcon, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx index 965c43f60e8..4c43eee4935 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/WriteFileTool.tsx @@ -25,19 +25,10 @@ export const WriteFileTool: React.FC<{ path: string; diff: FileDiffMetadata | null; status: ToolStatus; - hookRewritten?: boolean; isError: boolean; errorMessage?: string; codeDiffDisplayMode?: TypesGen.AgentDisplayMode; -}> = ({ - path, - diff, - status, - hookRewritten, - isError, - errorMessage, - codeDiffDisplayMode, -}) => { +}> = ({ path, diff, status, isError, errorMessage, codeDiffDisplayMode }) => { const theme = useTheme(); const isDark = theme.palette.mode === "dark"; const hasDiff = diff !== null; @@ -61,7 +52,6 @@ export const WriteFileTool: React.FC<{ return ( Date: Thu, 30 Jul 2026 17:54:25 +0000 Subject: [PATCH 11/16] test(site/src): require the policy badge to be visible The coverage loop used a text query, which matches a badge the card's own layout hides. Verified by hiding the badge with display:none, which the previous assertion accepted. --- .../components/ChatElements/tools/Tool.stories.tsx | 6 +++++- .../pages/AgentsPage/components/ChatElements/tools/Tool.tsx | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 6c011b48eae..d5040b11945 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -3352,7 +3352,11 @@ export const PolicyBadgeCoversEveryRenderer: Story = { return; } rendered.add(tool.name); - if (!within(toolCase).queryByText("Modified by policy")) { + // checkVisibility, not presence: a badge hidden by the card's own + // layout still satisfies a text query. + if ( + !within(toolCase).queryByText("Modified by policy")?.checkVisibility() + ) { missingBadge.push(tool.name); } }); diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index a44ba3d1d81..d1dca3c8743 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -1038,8 +1038,8 @@ const toolRenderers: Record> = { computer: ComputerRenderer, }; -// Exported so tests can assert cross-cutting header affordances across every -// dispatch target instead of a hand-picked subset. +// Exported so tests can assert cross-cutting affordances across every +// registered renderer instead of a hand-picked subset. export const toolRendererNames: readonly string[] = Object.keys(toolRenderers); // --------------------------------------------------------------------------- From aa411153ebb70c50b651ea92aedeb27efbf1e821 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:08:10 +0000 Subject: [PATCH 12/16] fix(site/src): forward the policy flag in the remaining-tools fallback The fallback that renders merged tools without a parsed block omitted hookRewritten, so a rewritten call reaching that path lost the badge. --- .../components/ChatConversation/ConversationTimeline.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 4f1be71f1f2..20bdf50b355 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -513,6 +513,7 @@ export const BlockList: FC<{ } modelIntent={tool.modelIntent} parsedCommands={tool.parsedCommands} + hookRewritten={tool.hookRewritten} /> ))} From 61b94b96b5d7b9a27cc0027cee679ab4ce7ff5b8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:28:05 +0000 Subject: [PATCH 13/16] fix(site/src): keep the read-file failure in the header button name The previous commit gave ReadFileTool an explicit ariaLabel so a grouped row could be told apart by name. An explicit aria-label replaces the name computed from descendants, dropping the failure text ToolCall.Status contributes, so a failed read announced only the filename. The grouped story reads attribution from the policy group instead, and ReadFileFailed guards the failure name. --- .../ConversationTimeline.stories.tsx | 14 ++++++++------ .../ChatElements/tools/ReadFileTool.tsx | 1 - .../ChatElements/tools/Tool.stories.tsx | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 2154431ce9e..b6b8b1f4c0f 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -2674,13 +2674,15 @@ export const GroupedReadFilesRewrittenByHook: Story = { await canvas.findByRole("button", { name: /Read 2 files/ }), ); expect( - await canvas.findByRole("button", { - name: "Read b.ts, modified by policy", - }), - ).toBeVisible(); - expect( - await canvas.findByRole("button", { name: "Read a.ts" }), + await canvas.findByRole("button", { name: /Read b\.ts/ }), ).toBeVisible(); + // Each rewritten call owns a group labelled by its badge, so the + // per-file attribution is readable from the group it encloses. + const attributed = canvas + .getAllByRole("group", { name: "Modified by policy" }) + .map((group) => group.textContent ?? ""); + expect(attributed.some((text) => text.includes("b.ts"))).toBe(true); + expect(attributed.some((text) => text.includes("a.ts"))).toBe(false); expect(canvas.getAllByText("Modified by policy")).toHaveLength(2); }); }, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx index f1454d6a944..e9462fa7935 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx @@ -92,7 +92,6 @@ export const ReadFileTool: React.FC<{ isError={isError} errorMessage={errorMessage || "Failed to read file"} hasContent={hasContent} - ariaLabel={label} expanded={expanded} onExpandedChange={onExpandedChange} > diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index d5040b11945..c571f2033d9 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -2426,6 +2426,24 @@ export const ReadFileLongLine: Story = { }, }; +export const ReadFileFailed: Story = { + args: { + name: "read_file", + status: "error", + isError: true, + args: { path: "site/src/config.ts" }, + result: { error: "permission denied" }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // An explicit ariaLabel on the row would replace the computed name and + // drop the failure text the status icon contributes. + expect( + canvas.getByRole("button", { name: /permission denied/ }), + ).toBeVisible(); + }, +}; + export const ReadFileTallAndWide: Story = { args: { name: "read_file", From e18c880d58cb1e71ee051ce098be8fb8dfb0d02a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:39:10 +0000 Subject: [PATCH 14/16] test(site/src): build the policy story workspace from shared fixtures The story seeded workspaceByIdKey with an untyped partial, so a change to the Workspace or WorkspaceBuild shape could not surface here. It now composes MockWorkspace and MockWorkspaceBuild under an explicit TypesGen.Workspace annotation, which rejects the previous partial. --- .../ChatElements/tools/Tool.stories.tsx | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index c571f2033d9..266a7bc7c04 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -4,7 +4,9 @@ import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { chatModelConfigsKey } from "#/api/queries/chats"; import { workspaceBuildLogs } from "#/api/queries/workspaceBuilds"; import { workspaceByIdKey } from "#/api/queries/workspaces"; +import type * as TypesGen from "#/api/typesGenerated"; import { MockChatModelConfig } from "#/testHelpers/chatModels"; +import { MockWorkspace, MockWorkspaceBuild } from "#/testHelpers/entities"; import { ChatWorkspaceContext } from "../../../context/ChatWorkspaceContext"; import { BlockList } from "../../ChatConversation/ConversationTimeline"; import { DesktopPanelContext } from "./DesktopPanelContext"; @@ -3303,6 +3305,20 @@ export const AllToolIconsTranscript: Story = { const policyCaseLabel = (name: string, index: number) => `policy case ${name} ${index}`; +// WorkspaceBuildLogSection falls back to the workspace's latest build when a +// tool result carries no build_id, so both must resolve to the seeded logs. +const showcaseBuildId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + +const policyCaseWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + id: "test-workspace-id", + latest_build: { + ...MockWorkspaceBuild, + id: showcaseBuildId, + workspace_id: "test-workspace-id", + }, +}; + export const PolicyBadgeCoversEveryRenderer: Story = { render: () => ( @@ -3341,14 +3357,10 @@ export const PolicyBadgeCoversEveryRenderer: Story = { queries: [ { key: workspaceByIdKey("test-workspace-id"), - data: { - id: "test-workspace-id", - latest_build: { id: "test-build-id", status: "running" }, - }, + data: policyCaseWorkspace, }, { - key: workspaceBuildLogs("a1b2c3d4-e5f6-7890-abcd-ef1234567890") - .queryKey, + key: workspaceBuildLogs(showcaseBuildId).queryKey, data: [], }, ], From 9a66f9473f6aa483d64308627cd0966d87c03930 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:10:25 +0000 Subject: [PATCH 15/16] style(site): trim policy attribution comments --- .../ChatConversation/ConversationTimeline.stories.tsx | 2 -- .../components/ChatElements/tools/Tool.stories.tsx | 3 --- .../components/ChatElements/tools/ToolCall.tsx | 9 +-------- 3 files changed, 1 insertion(+), 13 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index b6b8b1f4c0f..a08c233e5d3 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -2676,8 +2676,6 @@ export const GroupedReadFilesRewrittenByHook: Story = { expect( await canvas.findByRole("button", { name: /Read b\.ts/ }), ).toBeVisible(); - // Each rewritten call owns a group labelled by its badge, so the - // per-file attribution is readable from the group it encloses. const attributed = canvas .getAllByRole("group", { name: "Modified by policy" }) .map((group) => group.textContent ?? ""); diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 266a7bc7c04..3f6fd0b45e4 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -3299,9 +3299,6 @@ export const AllToolIconsTranscript: Story = { }, }; -// The badge reaches tools implicitly through the policy provider, so a -// renderer reachable only through a branch that skips it would drop the -// attribution with no type or runtime error. const policyCaseLabel = (name: string, index: number) => `policy case ${name} ${index}`; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx index 98bbc6e6de8..96f40f1148d 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx @@ -54,14 +54,7 @@ const ToolPolicyContext = createContext<{ hookRewritten: boolean }>({ hookRewritten: false, }); -/** - * Renders the badge itself rather than delegating to the shared header, - * so a renderer branch that returns early without a `ToolCall.Header` - * keeps the attribution. Renderers never receive `hookRewritten`. - * - * The group labelled by the badge binds the notice to the card it - * describes, which is what lets a nested row be attributed on its own. - */ +// Keep attribution at the provider boundary because renderer props omit `hookRewritten`. const PolicyProvider: FC<{ hookRewritten: boolean; children: ReactNode }> = ({ hookRewritten, children, From f0fa131fcb7653f43609c1a52d8101b9caa3ad32 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:31:22 +0000 Subject: [PATCH 16/16] style(site/src): state the verified reason the policy badge renders here --- .../AgentsPage/components/ChatElements/tools/ToolCall.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx index 96f40f1148d..bde2ddd5fbc 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolCall.tsx @@ -54,7 +54,8 @@ const ToolPolicyContext = createContext<{ hookRewritten: boolean }>({ hookRewritten: false, }); -// Keep attribution at the provider boundary because renderer props omit `hookRewritten`. +// Some renderer branches render no `ToolCall.Header`, so emitting the +// badge here rather than in the header keeps attribution on those cards. const PolicyProvider: FC<{ hookRewritten: boolean; children: ReactNode }> = ({ hookRewritten, children,