Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5caa5d1
fix: keep chat prompt attachments through a hook override
ibetitsmike Jul 29, 2026
5d54db6
fix: mark chat hook notices with a typed part
ibetitsmike Jul 29, 2026
5a729fa
feat: mark chat tool calls a lifecycle hook rewrote
ibetitsmike Jul 29, 2026
7e5e531
chore: remove unnecessary chat hook comments
ibetitsmike Jul 30, 2026
9fada0e
test(site/src): assert the policy badge covers every tool renderer
ibetitsmike Jul 30, 2026
d077838
refactor(site/src): own the tool policy flag in ToolCall.Root
ibetitsmike Jul 30, 2026
d51a378
refactor(site/src): require every registry name in the badge story
ibetitsmike Jul 30, 2026
df3bf63
test(site/src): query the badge coverage cases by test id
ibetitsmike Jul 30, 2026
a2c4a9b
fix(coderd/x/chatd): skip rewrite attribution for provider-executed c…
ibetitsmike Jul 30, 2026
919cae8
revert(site/src): stop piping the policy flag through every renderer
ibetitsmike Jul 30, 2026
c8259c6
test(site/src): require the policy badge to be visible
ibetitsmike Jul 30, 2026
aa41115
fix(site/src): forward the policy flag in the remaining-tools fallback
ibetitsmike Jul 30, 2026
61b94b9
fix(site/src): keep the read-file failure in the header button name
ibetitsmike Jul 30, 2026
e18c880
test(site/src): build the policy story workspace from shared fixtures
ibetitsmike Jul 30, 2026
9a66f94
style(site): trim policy attribution comments
ibetitsmike Aug 3, 2026
f0fa131
style(site/src): state the verified reason the policy badge renders here
ibetitsmike Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 38 additions & 14 deletions coderd/exp_chats_hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{{
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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)

Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
}
Expand Down
26 changes: 24 additions & 2 deletions coderd/x/chatd/chathooks/effects.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
62 changes: 62 additions & 0 deletions coderd/x/chatd/chathooks/hooks_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
})
}
13 changes: 7 additions & 6 deletions coderd/x/chatd/generation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
11 changes: 10 additions & 1 deletion coderd/x/chatd/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 14 additions & 7 deletions coderd/x/chatd/message_conversion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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]
Comment thread
ibetitsmike marked this conversation as resolved.
}
case codersdk.ChatMessagePartTypeToolResult:
if part.ToolCallID != "" && step.ToolResultCreatedAt != nil {
if ts, ok := step.ToolResultCreatedAt[part.ToolCallID]; ok {
Expand Down
Loading
Loading