diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3ce8e342ab0..486935a48fb 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 that a lifecycle hook replaced model-proposed tool input.", + "type": "boolean" + }, "is_error": { "type": "boolean" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 92eecc3601c..1d38140c3da 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -16158,6 +16158,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" }, diff --git a/coderd/exp_chats_hooks_test.go b/coderd/exp_chats_hooks_test.go index f1f1db93a58..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{{ @@ -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 } } diff --git a/coderd/x/chatd/chathooks/effects.go b/coderd/x/chatd/chathooks/effects.go index e0f82eb26b2..7b70b75a2e3 100644 --- a/coderd/x/chatd/chathooks/effects.go +++ b/coderd/x/chatd/chathooks/effects.go @@ -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 { 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/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/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/coderd/x/chatd/message_conversion.go b/coderd/x/chatd/message_conversion.go index 82fcc8c2029..d03ae1462fe 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,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 { diff --git a/coderd/x/chatd/message_conversion_test.go b/coderd/x/chatd/message_conversion_test.go index 40eb2fa291d..f49f1fa2138 100644 --- a/coderd/x/chatd/message_conversion_test.go +++ b/coderd/x/chatd/message_conversion_test.go @@ -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) +} diff --git a/codersdk/chats.go b/codersdk/chats.go index 7c47bead863..b44bed51c81 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" ) @@ -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 diff --git a/docs/admin/setup/chat-lifecycle-hooks.md b/docs/admin/setup/chat-lifecycle-hooks.md index 1487cf7f56f..e33cea7991e 100644 --- a/docs/admin/setup/chat-lifecycle-hooks.md +++ b/docs/admin/setup/chat-lifecycle-hooks.md @@ -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. 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..9de2d6a3bee 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 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 | | | @@ -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..cbae3b6d914 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3556,6 +3556,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 diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 2e120d34a57..a08c233e5d3 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 }], @@ -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, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 6a84edcc895..20bdf50b355 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -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 ( -