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 ? (
) : (
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 = ({
{children}
+
);
@@ -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
)}
-
+
);
};
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,