feat: show what a chat lifecycle hook changed (#27655)

## Stack Context

Follow-up fixes from live UAT of the merged chat lifecycle hooks stack
(#27430). Two PRs:

1. **This PR**: make hook effects visible and correctly attributed in
the transcript.
2. [`mike/chat-hooks-uat/dispatch-capacity`]: reserve dispatch capacity
so an admission burst can't fail running turns.

## Why?

UAT found three ways the transcript misrepresented what a lifecycle hook
did. All three are user-visible and share the same surface
(`chathooks/effects.go`, `codersdk.ChatMessagePart`, the conversation
timeline), so they're reviewed together.

**A prompt `input_override` silently discarded attachments.**
`ComposeUserPromptContent` replaced the entire submitted part list with
one text part, dropping `file` and `file-reference` parts along with
their `chat_file_links`. The user saw their attachments vanish with no
explanation. The override now replaces submitted *text* parts only and
preserves non-text parts in order. A consumer that wants to block
attachments uses `deny`, which is the documented mechanism for refusing
a submission.

**Every user-visible `system` row was labelled "Lifecycle hook".** The
timeline keyed the notice off `role === "system"`. That was correct only
by accident, because the hook `user_message` was the sole client-visible
system row. The backend now emits the notice as a typed `hook-notice`
part and the timeline renders on that, so a future system row can't be
mislabelled as a policy notice.

**Nothing marked a tool call the hook had rewritten.** A consumer could
replace tool input via `input_override` and the transcript showed the
rewritten input as if the model had produced it. `ChatMessagePart` gains
`hook_rewritten`, set from `preflight.Overrides` on the same path that
already carries `ToolCallCreatedAt`, and the tool row renders a
"Modified by policy" badge.

`ToolCall.PolicyProvider` renders the badge itself, at four wrap sites:
the `Tool` dispatch wrapper, the `ReadFilesTool` aggregate and its
per-file rows, and `ReadFileTimelineBlock` (grouped and single
`read_file` rows bypass `Tool`). Renderer props do not include the flag;
descendants consume it through the provider context.

The badge is emitted by the provider rather than by the shared header
because several renderer branches return early without one, including
the auth-required `execute` card, a completed `ask_user_question`, and
an empty question payload. Those branches would drop the attribution
with no type or runtime error, and the gap is not greppable: every
renderer file contains a header somewhere, only individual branches do
not. Emitting at the provider removes the possibility instead of
enumerating the cases.

A rewritten call is wrapped in a group labelled by its badge, so one
rewritten file inside a merged read is attributed on its own rather than
inheriting the group's badge. `HeaderButton` still appends the policy
wording to an explicit `ariaLabel`, since an explicit `aria-label`
replaces the name computed from descendants.

Provider-executed calls are excluded from attribution. Hooks never see
them, and duplicate tool-call ID rejection deliberately skips them, so a
reused ID would otherwise mark a provider-executed call as
policy-rewritten.

## Testing

Go: `coderd/x/chatd/...`, `coderd/x/agenthooks/...`, `codersdk/...`, and
`coderd -run 'Hook|Chat'`. Frontend: `tsc` plus every `AgentsPage`
story; the only failures are `MCP Tool Completed` and `Scroll To Bottom
Button Works With Inverse Scroll`, both of which fail on trunk.

A registry-wide story asserts every registered renderer shows the badge,
verified against three inverted toggles: removing the badge, hiding it
with `display:none`, and skipping the provider for one renderer (which
names that renderer). Storybook also covers the rewritten subagent
spawn, a completed empty question payload, a non-hook system message,
and a failed `read_file` guarding the accessible name.

> Mux opened this PR on Mike's behalf.
This commit is contained in:
Michael Suchacz
2026-08-03 18:27:39 +02:00
committed by GitHub
parent 4245e4e378
commit df1c0f9710
25 changed files with 797 additions and 85 deletions
+4
View File
@@ -17945,6 +17945,10 @@ const docTemplate = `{
"file_name": {
"type": "string"
},
"hook_rewritten": {
"description": "HookRewritten indicates that a lifecycle hook replaced model-proposed tool input.",
"type": "boolean"
},
"is_error": {
"type": "boolean"
},
+4
View File
@@ -16165,6 +16165,10 @@
"file_name": {
"type": "string"
},
"hook_rewritten": {
"description": "HookRewritten indicates that a lifecycle hook replaced model-proposed tool input.",
"type": "boolean"
},
"is_error": {
"type": "boolean"
},
+38 -14
View File
@@ -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{{
@@ -343,7 +352,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
}
}
@@ -410,19 +419,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 +447,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) {
@@ -576,7 +600,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
}
}
+24 -2
View File
@@ -36,7 +36,10 @@ func EventMessages(result *Result, modelConfigID uuid.UUID) ([]chatstate.Message
})
}
if result.GetUserMessage() != "" {
content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText(result.UserMessage)})
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)
}
@@ -156,6 +159,25 @@ func UserPromptParts(result *Result) []codersdk.ChatMessagePart {
return parts
}
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 +189,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 {
@@ -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)
})
}
+7 -6
View File
@@ -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))
+10 -1
View File
@@ -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)
+14 -7
View File
@@ -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,11 @@ func buildAssistantParts(
part.CreatedAt = &ts
}
}
// 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:
if part.ToolCallID != "" && step.ToolResultCreatedAt != nil {
if ts, ok := step.ToolResultCreatedAt[part.ToolCallID]; ok {
+69
View File
@@ -832,3 +832,72 @@ 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)
}
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)
}
+5 -3
View File
@@ -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"
)
@@ -380,6 +380,8 @@ 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 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
// it is the time the call was emitted or the result was
+4 -1
View File
@@ -121,10 +121,13 @@ 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.
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.
+10
View File
@@ -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": {
+15
View File
@@ -2884,6 +2884,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": {
@@ -2979,6 +2980,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": {
@@ -3035,6 +3037,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 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 | | |
@@ -3148,6 +3151,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": {
@@ -3230,6 +3234,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": {
@@ -3459,6 +3464,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": {
@@ -3624,6 +3630,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": {
@@ -3704,6 +3711,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": {
@@ -3772,6 +3780,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": {
@@ -3889,6 +3898,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": {
@@ -4590,6 +4600,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": {
@@ -4671,6 +4682,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": {
@@ -4753,6 +4765,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": {
@@ -7289,6 +7302,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": {
@@ -7370,6 +7384,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": {
+4
View File
@@ -3564,6 +3564,10 @@ export interface ChatToolCallPart {
* the provider (e.g. Anthropic computer use).
*/
readonly provider_executed?: boolean;
/**
* HookRewritten indicates that a lifecycle hook replaced model-proposed tool input.
*/
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
@@ -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 }],
@@ -415,7 +418,7 @@ export const LifecycleHookNotice: Story = {
role: "system",
content: [
{
type: "text",
type: "hook-notice",
text: "Your organization requires an approval before deployment.",
},
],
@@ -438,6 +441,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,
@@ -2596,6 +2623,89 @@ 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/ }),
);
expect(
await canvas.findByRole("button", { name: /Read b\.ts/ }),
).toBeVisible();
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);
});
},
};
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,
@@ -222,18 +222,19 @@ const ReadFileTimelineBlock = memo<{
}>(({ tools }) => {
const [expanded, setExpanded] = useState(false);
const [firstTool] = tools;
if (tools.length === 1) {
const readFile = getReadFileToolData(firstTool);
return (
<div data-tool-call="">
<ReadFileTool
{...readFile}
status={firstTool.status}
expanded={expanded}
onExpandedChange={setExpanded}
/>
</div>
<ToolCall.PolicyProvider hookRewritten={firstTool.hookRewritten ?? false}>
<div data-tool-call="">
<ReadFileTool
{...readFile}
status={firstTool.status}
expanded={expanded}
onExpandedChange={setExpanded}
/>
</div>
</ToolCall.PolicyProvider>
);
}
@@ -451,6 +452,7 @@ export const BlockList: FC<{
}
modelIntent={tool.modelIntent}
parsedCommands={tool.parsedCommands}
hookRewritten={tool.hookRewritten}
/>
);
}
@@ -511,6 +513,7 @@ export const BlockList: FC<{
}
modelIntent={tool.modelIntent}
parsedCommands={tool.parsedCommands}
hookRewritten={tool.hookRewritten}
/>
))}
</>
@@ -631,9 +634,20 @@ const ChatMessageItem = memo<{
// Keep links in dimmed notices out of accessibility navigation.
inert={isAfterEditingMessage ? true : undefined}
>
<LifecycleHookNotice urlTransform={urlTransform}>
{parsed.markdown}
</LifecycleHookNotice>
{parsed.hookNotices.length > 0 ? (
parsed.hookNotices.map((notice, index) => (
<LifecycleHookNotice
key={`${message.id}-hook-notice-${index}`}
urlTransform={urlTransform}
>
{notice}
</LifecycleHookNotice>
))
) : (
<TimelineNotice>
<Response urlTransform={urlTransform}>{parsed.markdown}</Response>
</TimelineNotice>
)}
</div>
);
}
@@ -68,7 +68,8 @@ const getRenderableContentState = (parsed: ParsedMessageContent) => {
const hasRenderableContent =
visibleBlocks.length > 0 ||
visibleTools.length > 0 ||
parsed.sources.length > 0;
parsed.sources.length > 0 ||
parsed.hookNotices.length > 0;
const hasThinkingOnlyContent =
visibleBlocks.length > 0 &&
visibleBlocks.every((block) => block.type === "thinking");
@@ -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;
@@ -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";
};
@@ -456,6 +456,59 @@ 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 CompletedEmptyPayloadRewrittenByHook: Story = {
args: {
status: "completed",
result: JSON.stringify({ questions: [] }),
isChatCompleted: true,
isLatestAskUserQuestion: false,
hookRewritten: true,
},
play: async ({ canvasElement }) => {
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",
@@ -692,7 +692,6 @@ export const AskUserQuestionTool: FC<AskUserQuestionToolProps> = ({
/>
</ToolCall.Root>
)}
{isInteractive ? (
<form onSubmit={handleFormSubmit}>{content}</form>
) : (
@@ -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,18 @@ export const ReadFilesTool: FC<{
expanded={expanded}
onExpandedChange={onExpandedChange}
>
<ToolCall.Header iconName="read_file" label={label} />
<ToolCall.PolicyProvider
hookRewritten={items.some((item) => item.hookRewritten)}
>
<ToolCall.Header iconName="read_file" label={label} />
</ToolCall.PolicyProvider>
<ToolCall.Content>
<div className="space-y-1 py-0.5 pl-3">
{items.map((item) => (
<div key={item.id}>
<ToolCall.PolicyProvider
key={item.id}
hookRewritten={item.hookRewritten}
>
<ReadFileTool
path={item.path}
content={item.content}
@@ -70,7 +79,7 @@ export const ReadFilesTool: FC<{
});
}}
/>
</div>
</ToolCall.PolicyProvider>
))}
</div>
</ToolCall.Content>
@@ -2,11 +2,15 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test";
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";
import { Tool } from "./Tool";
import { Tool, toolRendererNames } from "./Tool";
const executeCommand = "git fetch origin";
const executeIntentCommand = "npm test";
@@ -127,6 +131,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" },
@@ -394,6 +405,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 +413,8 @@ 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();
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 +424,117 @@ 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(canvas.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);
expect(
canvas.getByRole("button", { name: /Spawned Workspace diagnostics/ }),
).toBeVisible();
expect(canvas.getByText("Modified by policy")).toBeVisible();
},
};
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",
@@ -2307,6 +2428,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",
@@ -3159,3 +3298,99 @@ 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: () => (
<ChatWorkspaceContext value={{ workspaceId: "test-workspace-id" }}>
<DesktopPanelContext.Provider
value={{ desktopChatId: "desktop-child", onOpenDesktop: fn() }}
>
<div className="flex flex-col gap-2">
{allToolShowcaseItems.map((tool, index) => (
<div
key={`${tool.name}-${index}`}
role="group"
aria-label={policyCaseLabel(tool.name, index)}
>
<Tool
name={tool.name}
status={tool.status ?? "completed"}
args={tool.args}
result={tool.result}
isError={tool.isError}
killedBySignal={tool.killedBySignal}
modelIntent={tool.modelIntent}
parsedCommands={tool.parsedCommands}
subagentVariants={tool.subagentVariants}
hookRewritten
shellToolDisplayMode="always_collapsed"
codeDiffDisplayMode="always_collapsed"
showDesktopPreviews={false}
/>
</div>
))}
</div>
</DesktopPanelContext.Provider>
</ChatWorkspaceContext>
),
parameters: {
queries: [
{
key: workspaceByIdKey("test-workspace-id"),
data: policyCaseWorkspace,
},
{
key: workspaceBuildLogs(showcaseBuildId).queryKey,
data: [],
},
],
},
play: async ({ canvasElement }) => {
const covered = new Set(allToolShowcaseItems.map((tool) => tool.name));
expect(
toolRendererNames.filter((name) => !covered.has(name)),
).toStrictEqual([]);
const canvas = within(canvasElement);
const rendered = new Set<string>();
const missingBadge: string[] = [];
allToolShowcaseItems.forEach((tool, index) => {
const toolCase = canvas.getByRole("group", {
name: policyCaseLabel(tool.name, index),
});
if (toolCase.textContent?.trim() === "") {
return;
}
rendered.add(tool.name);
// 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);
}
});
expect(missingBadge).toStrictEqual([]);
expect(
toolRendererNames.filter((name) => !rendered.has(name)),
).toStrictEqual([]);
},
};
@@ -88,6 +88,7 @@ interface ToolProps extends Omit<ComponentPropsWithRef<"div">, "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;
}
@@ -1037,6 +1038,10 @@ export const toolRenderers: Record<string, FC<ToolRendererProps>> = {
computer: ComputerRenderer,
};
// 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);
// ---------------------------------------------------------------------------
// Public Tool component with a single wrapper div and map dispatch.
// ---------------------------------------------------------------------------
@@ -1063,6 +1068,7 @@ export const Tool = memo(
previousResponseText,
modelIntent,
parsedCommands,
hookRewritten = false,
shellToolDisplayMode,
codeDiffDisplayMode,
ref,
@@ -1088,29 +1094,31 @@ export const Tool = memo(
)}
{...props}
>
<Renderer
name={name}
status={status}
args={args}
result={result}
isError={isError}
killedBySignal={killedBySignal}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
showDesktopPreviews={showDesktopPreviews}
subagentStatusOverrides={subagentStatusOverrides}
mcpServerConfigId={mcpServerConfigId}
mcpServers={mcpServers}
onImplementPlan={onImplementPlan}
onSendAskUserQuestionResponse={onSendAskUserQuestionResponse}
isChatCompleted={isChatCompleted}
isLatestAskUserQuestion={isLatestAskUserQuestion}
previousResponseText={previousResponseText}
modelIntent={modelIntent}
parsedCommands={parsedCommands}
shellToolDisplayMode={shellToolDisplayMode}
codeDiffDisplayMode={codeDiffDisplayMode}
/>
<ToolCall.PolicyProvider hookRewritten={hookRewritten}>
<Renderer
name={name}
status={status}
args={args}
result={result}
isError={isError}
killedBySignal={killedBySignal}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
showDesktopPreviews={showDesktopPreviews}
subagentStatusOverrides={subagentStatusOverrides}
mcpServerConfigId={mcpServerConfigId}
mcpServers={mcpServers}
onImplementPlan={onImplementPlan}
onSendAskUserQuestionResponse={onSendAskUserQuestionResponse}
isChatCompleted={isChatCompleted}
isLatestAskUserQuestion={isLatestAskUserQuestion}
previousResponseText={previousResponseText}
modelIntent={modelIntent}
parsedCommands={parsedCommands}
shellToolDisplayMode={shellToolDisplayMode}
codeDiffDisplayMode={codeDiffDisplayMode}
/>
</ToolCall.PolicyProvider>
</div>
);
},
@@ -1,10 +1,16 @@
import { ChevronDownIcon, LoaderIcon, TriangleAlertIcon } from "lucide-react";
import {
ChevronDownIcon,
LoaderIcon,
ShieldIcon,
TriangleAlertIcon,
} from "lucide-react";
import {
type ComponentPropsWithoutRef,
createContext,
type FC,
type ReactNode,
useContext,
useId,
useState,
} from "react";
import {
@@ -44,6 +50,37 @@ type ToolCallContextValue = {
const ToolCallContext = createContext<ToolCallContextValue | null>(null);
const ToolPolicyContext = createContext<{ hookRewritten: boolean }>({
hookRewritten: false,
});
// 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,
}) => {
const badgeId = useId();
return (
<ToolPolicyContext.Provider value={{ hookRewritten }}>
{hookRewritten ? (
<div role="group" aria-labelledby={badgeId}>
<span
id={badgeId}
className="mb-0.5 flex w-fit items-center gap-1 rounded border border-solid border-border-default px-1 text-[11px] leading-4 text-content-secondary"
>
<ShieldIcon aria-hidden className="size-3 shrink-0" />
Modified by policy
</span>
{children}
</div>
) : (
children
)}
</ToolPolicyContext.Provider>
);
};
const useToolCallContext = () => {
const context = useContext(ToolCallContext);
if (!context) {
@@ -173,6 +210,13 @@ const HeaderButton: FC<ToolCallHeaderButtonProps> = ({
alwaysButton = false,
}) => {
const { ariaLabel, collapsible, expanded, onToggle } = useToolCallContext();
const { hookRewritten } = useContext(ToolPolicyContext);
const resolvedAriaLabel =
typeof ariaLabel === "function" ? ariaLabel(expanded) : ariaLabel;
const buttonAriaLabel =
resolvedAriaLabel && hookRewritten
? `${resolvedAriaLabel}, modified by policy`
: resolvedAriaLabel;
if (!collapsible && !alwaysButton) {
return (
<HeaderRow className={cn("min-w-0", className)}>{children}</HeaderRow>
@@ -191,9 +235,7 @@ const HeaderButton: FC<ToolCallHeaderButtonProps> = ({
<button
type="button"
aria-expanded={collapsible ? expanded : undefined}
aria-label={
typeof ariaLabel === "function" ? ariaLabel(expanded) : ariaLabel
}
aria-label={buttonAriaLabel}
onClick={collapsible ? onToggle : undefined}
>
{children}
@@ -305,7 +347,7 @@ const Chevron: FC<{ className?: string }> = ({ className }) => {
return (
<ChevronDownIcon
className={cn(
"size-3 shrink-0 text-current transition-transform",
"order-last size-3 shrink-0 text-current transition-transform",
expanded ? "rotate-0" : "-rotate-90",
className,
)}
@@ -407,6 +449,7 @@ const Content: FC<ToolCallContentProps> = ({ children }) => {
export const ToolCall = {
Root,
PolicyProvider,
HeaderRow,
HeaderButton,
LeadingIcon,